From 3681c8846b0801a0dd96bea2975104bdf6f2401a Mon Sep 17 00:00:00 2001 From: Arman Bilge Date: Sun, 3 Oct 2021 19:05:01 +0000 Subject: [PATCH 1/4] Make Dns cross-platform, add reverse method --- build.sbt | 13 +-- .../scala/com/comcast/ip4s/DnsPlatform.scala | 104 ++++++++++++++++++ .../comcast/ip4s/UnknownHostException.scala | 27 +++++ .../scala/com/comcast/ip4s/ip4splatform.scala | 19 ++++ .../scala/com/comcast/ip4s/DnsPlatform.scala | 62 +++++++++++ .../scala/com/comcast/ip4s/ip4splatform.scala | 21 ++++ shared/src/main/scala-2/package.scala | 2 +- shared/src/main/scala-3/package.scala | 2 +- .../src/main/scala/com/comcast/ip4s/Dns.scala | 57 ++++------ .../test/scala/com/comcast/ip4s/DnsTest.scala | 49 +++++++++ 10 files changed, 313 insertions(+), 43 deletions(-) create mode 100644 js/src/main/scala/com/comcast/ip4s/DnsPlatform.scala create mode 100644 js/src/main/scala/com/comcast/ip4s/UnknownHostException.scala create mode 100644 js/src/main/scala/com/comcast/ip4s/ip4splatform.scala create mode 100644 jvm/src/main/scala/com/comcast/ip4s/DnsPlatform.scala create mode 100644 jvm/src/main/scala/com/comcast/ip4s/ip4splatform.scala rename {jvm => shared}/src/main/scala/com/comcast/ip4s/Dns.scala (60%) create mode 100644 test-kit/shared/src/test/scala/com/comcast/ip4s/DnsTest.scala diff --git a/build.sbt b/build.sbt index 48b59005..302da24a 100644 --- a/build.sbt +++ b/build.sbt @@ -64,7 +64,9 @@ lazy val testKit = crossProject(JVMPlatform, JSPlatform) .settings( libraryDependencies ++= Seq( "org.scalacheck" %%% "scalacheck" % "1.15.4", - "org.scalameta" %%% "munit-scalacheck" % "0.7.29" % Test + "org.scalameta" %%% "munit-scalacheck" % "0.7.29" % Test, + "org.typelevel" %%% "cats-effect" % "3.2.9" % Test, + "org.typelevel" %%% "munit-cats-effect-3" % "1.0.6" % Test, ) ) .jvmSettings( @@ -107,16 +109,11 @@ lazy val core = crossProject(JVMPlatform, JSPlatform) ) } ) - .settings( - libraryDependencies += "org.typelevel" %%% "literally" % "1.0.2" - ) - .jvmSettings( - libraryDependencies += "org.typelevel" %%% "cats-effect" % "3.2.9" - ) .settings( libraryDependencies ++= Seq( + "org.typelevel" %%% "literally" % "1.0.2", "org.typelevel" %%% "cats-core" % "2.6.1", - "org.scalacheck" %%% "scalacheck" % "1.15.4" % Test + "org.typelevel" %%% "cats-effect-kernel" % "3.2.9", ) ) diff --git a/js/src/main/scala/com/comcast/ip4s/DnsPlatform.scala b/js/src/main/scala/com/comcast/ip4s/DnsPlatform.scala new file mode 100644 index 00000000..3b8642cc --- /dev/null +++ b/js/src/main/scala/com/comcast/ip4s/DnsPlatform.scala @@ -0,0 +1,104 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * 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 com.comcast.ip4s + +import cats.effect.kernel.Async +import cats.syntax.all._ + +import scala.scalajs.js +import scala.scalajs.js.| +import scala.scalajs.js.annotation.JSImport + +trait DnsCompanionPlatform { + implicit def forAsync[F[_]](implicit F: Async[F]): Dns[F] = new UnsealedDns[F] { + def resolve(hostname: Hostname): F[IpAddress] = + F.fromPromise(F.delay(dnsPromises.lookup(hostname.toString, LookupOptions(all = false)))) + .flatMap { address => + IpAddress + .fromString(address.asInstanceOf[LookupResult].address) + .liftTo[F](new RuntimeException("Node.js returned invalid IP address")) + } + .adaptError { + case ex @ js.JavaScriptException(error: js.Error) if error.message.contains("ENOTFOUND") => + new JavaScriptUnknownHostException(hostname.toString, ex) + } + + def resolveOption(hostname: Hostname): F[Option[IpAddress]] = + resolve(hostname).map(_.some).recover { case _: UnknownHostException => None } + + def resolveAll(hostname: Hostname): F[List[IpAddress]] = + F.fromPromise(F.delay(dnsPromises.lookup(hostname.toString, LookupOptions(all = true)))) + .flatMap { addresses => + addresses + .asInstanceOf[js.Array[LookupResult]] + .toList + .traverse { address => + IpAddress + .fromString(address.address) + .liftTo[F](new RuntimeException("Node.js returned invalid IP address")) + } + } + .recover { + case js.JavaScriptException(error: js.Error) if error.message.contains("ENOTFOUND") => + Nil + } + + def reverse(address: IpAddress): F[Hostname] = + reverseAllOrError(address).flatMap(_.headOption.liftTo(new UnknownHostException(address.toString))) + + def reverseOption(address: IpAddress): F[Option[Hostname]] = reverseAll(address).map(_.headOption) + + def reverseAll(address: IpAddress): F[List[Hostname]] = + reverseAllOrError(address).recover { case _: UnknownHostException => Nil } + + private def reverseAllOrError(address: IpAddress): F[List[Hostname]] = + F.fromPromise(F.delay(dnsPromises.reverse(address.toString))) + .flatMap { hostnames => + hostnames.toList.traverse { hostname => + Hostname + .fromString(hostname) + .liftTo[F](new RuntimeException("Node.js returned invalid hostname")) + } + } + .adaptError { + case ex @ js.JavaScriptException(error: js.Error) if error.message.contains("ENOTFOUND") => + new JavaScriptUnknownHostException(address.toString, ex) + } + + def loopback: F[IpAddress] = resolve(Hostname.fromString("localhost").get) + } +} + +@js.native +@JSImport("dns", "promises") +private[ip4s] object dnsPromises extends js.Any { + + def lookup(hostname: String, options: LookupOptions): js.Promise[LookupResult | js.Array[LookupResult]] = js.native + + def reverse(ip: String): js.Promise[js.Array[String]] = js.native +} + +private[ip4s] sealed trait LookupOptions extends js.Object +object LookupOptions { + def apply(all: Boolean): LookupOptions = js.Dynamic.literal(all = all).asInstanceOf[LookupOptions] +} + +@js.native +private[ip4s] sealed trait LookupResult extends js.Object { + def address: String = js.native + def family: Int = js.native +} diff --git a/js/src/main/scala/com/comcast/ip4s/UnknownHostException.scala b/js/src/main/scala/com/comcast/ip4s/UnknownHostException.scala new file mode 100644 index 00000000..171f2540 --- /dev/null +++ b/js/src/main/scala/com/comcast/ip4s/UnknownHostException.scala @@ -0,0 +1,27 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * 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 com.comcast.ip4s + +import java.io.IOException +import scala.scalajs.js +import scala.util.control.NoStackTrace + +class UnknownHostException(message: String = null, cause: Throwable = null) extends IOException(message, cause) + +private[ip4s] class JavaScriptUnknownHostException(message: String, cause: js.JavaScriptException) + extends UnknownHostException(message, cause) + with NoStackTrace diff --git a/js/src/main/scala/com/comcast/ip4s/ip4splatform.scala b/js/src/main/scala/com/comcast/ip4s/ip4splatform.scala new file mode 100644 index 00000000..57b6ccbf --- /dev/null +++ b/js/src/main/scala/com/comcast/ip4s/ip4splatform.scala @@ -0,0 +1,19 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * 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 com.comcast + +private[comcast] trait ip4splatform diff --git a/jvm/src/main/scala/com/comcast/ip4s/DnsPlatform.scala b/jvm/src/main/scala/com/comcast/ip4s/DnsPlatform.scala new file mode 100644 index 00000000..d4a6262b --- /dev/null +++ b/jvm/src/main/scala/com/comcast/ip4s/DnsPlatform.scala @@ -0,0 +1,62 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * 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 com.comcast.ip4s + +import cats.effect.kernel.Sync +import cats.syntax.all._ + +import java.net.InetAddress + +trait DnsCompanionPlatform { + implicit def forSync[F[_]](implicit F: Sync[F]): Dns[F] = new UnsealedDns[F] { + def resolve(hostname: Hostname): F[IpAddress] = + F.blocking { + val addr = InetAddress.getByName(hostname.toString) + IpAddress.fromBytes(addr.getAddress).get + } + + def resolveOption(hostname: Hostname): F[Option[IpAddress]] = + resolve(hostname).map(_.some).recover { case _: UnknownHostException => None } + + def resolveAll(hostname: Hostname): F[List[IpAddress]] = + F.blocking { + try { + val addrs = InetAddress.getAllByName(hostname.toString) + addrs.toList.flatMap(addr => IpAddress.fromBytes(addr.getAddress)) + } catch { + case _: UnknownHostException => Nil + } + } + + def reverse(address: IpAddress): F[Hostname] = + F.blocking { + address.toInetAddress.getCanonicalHostName + } flatMap { hn => + // getCanonicalHostName returns the IP address as a string on failure + Hostname.fromString(hn).liftTo[F](new UnknownHostException(address.toString)) + } + + def reverseOption(address: IpAddress): F[Option[Hostname]] = + reverse(address).map(_.some).recover { case _: UnknownHostException => None } + + def reverseAll(address: IpAddress): F[List[Hostname]] = + reverseOption(address).map(_.toList) + + def loopback: F[IpAddress] = + F.blocking(IpAddress.fromInetAddress(InetAddress.getByName(null))) + } +} diff --git a/jvm/src/main/scala/com/comcast/ip4s/ip4splatform.scala b/jvm/src/main/scala/com/comcast/ip4s/ip4splatform.scala new file mode 100644 index 00000000..ed5d8fe9 --- /dev/null +++ b/jvm/src/main/scala/com/comcast/ip4s/ip4splatform.scala @@ -0,0 +1,21 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * 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 com.comcast + +private[comcast] trait ip4splatform { + type UnknownHostException = java.net.UnknownHostException +} diff --git a/shared/src/main/scala-2/package.scala b/shared/src/main/scala-2/package.scala index 53183856..1b511197 100644 --- a/shared/src/main/scala-2/package.scala +++ b/shared/src/main/scala-2/package.scala @@ -16,7 +16,7 @@ package com.comcast -package object ip4s { +package object ip4s extends ip4splatform { final implicit class IpLiteralSyntax(val sc: StringContext) extends AnyVal { def ip(args: Any*): IpAddress = macro Literals.ip.make def ipv4(args: Any*): Ipv4Address = diff --git a/shared/src/main/scala-3/package.scala b/shared/src/main/scala-3/package.scala index 33c6b6eb..9e517087 100644 --- a/shared/src/main/scala-3/package.scala +++ b/shared/src/main/scala-3/package.scala @@ -16,4 +16,4 @@ package com.comcast -package object ip4s +package object ip4s extends ip4splatform diff --git a/jvm/src/main/scala/com/comcast/ip4s/Dns.scala b/shared/src/main/scala/com/comcast/ip4s/Dns.scala similarity index 60% rename from jvm/src/main/scala/com/comcast/ip4s/Dns.scala rename to shared/src/main/scala/com/comcast/ip4s/Dns.scala index 7b83dfbb..de8ec6fd 100644 --- a/jvm/src/main/scala/com/comcast/ip4s/Dns.scala +++ b/shared/src/main/scala/com/comcast/ip4s/Dns.scala @@ -16,20 +16,15 @@ package com.comcast.ip4s -import cats.effect.Sync -import cats.syntax.all._ - -import java.net.{InetAddress, UnknownHostException} - /** Capability for an effect `F[_]` which can do DNS lookups. * - * An instance is available for any effect which has a `Sync` instance. + * An instance is available for any effect which has a `Sync` instance on JVM and `Async` on Node.js. */ -trait Dns[F[_]] { +sealed trait Dns[F[_]] { /** Resolves the supplied hostname to an ip address using the platform DNS resolver. * - * If the hostname cannot be resolved, the effect fails with a `java.net.UnknownHostException`. + * If the hostname cannot be resolved, the effect fails with an `UnknownHostException`. */ def resolve(hostname: Hostname): F[IpAddress] @@ -45,34 +40,30 @@ trait Dns[F[_]] { */ def resolveAll(hostname: Hostname): F[List[IpAddress]] - /** Gets an IP address representing the loopback interface. */ - def loopback: F[IpAddress] -} + /** Reverses the supplied address to a hostname using the platform DNS resolver. + * + * If the address cannot be reversed, the effect fails with an `UnknownHostException`. + */ + def reverse(address: IpAddress): F[Hostname] -object Dns { - def apply[F[_]](implicit F: Dns[F]): F.type = F + /** Reverses the supplied address to a hostname using the platform DNS resolver. + * + * If the address cannot be resolved, a `None` is returned. + */ + def reverseOption(address: IpAddress): F[Option[Hostname]] - implicit def forSync[F[_]](implicit F: Sync[F]): Dns[F] = new Dns[F] { - def resolve(hostname: Hostname): F[IpAddress] = - F.blocking { - val addr = InetAddress.getByName(hostname.toString) - IpAddress.fromBytes(addr.getAddress).get - } + /** Reverses the supplied address to all hostnames known to the platform DNS resolver. + * + * If the address cannot be resolved, an empty list is returned. + */ + def reverseAll(address: IpAddress): F[List[Hostname]] - def resolveOption(hostname: Hostname): F[Option[IpAddress]] = - resolve(hostname).map(Some(_): Option[IpAddress]).recover { case _: UnknownHostException => None } + /** Gets an IP address representing the loopback interface. */ + def loopback: F[IpAddress] +} - def resolveAll(hostname: Hostname): F[List[IpAddress]] = - F.blocking { - try { - val addrs = InetAddress.getAllByName(hostname.toString) - addrs.toList.flatMap(addr => IpAddress.fromBytes(addr.getAddress)) - } catch { - case _: UnknownHostException => Nil - } - } +private[ip4s] trait UnsealedDns[F[_]] extends Dns[F] - def loopback: F[IpAddress] = - F.blocking(IpAddress.fromInetAddress(InetAddress.getByName(null))) - } +object Dns extends DnsCompanionPlatform { + def apply[F[_]](implicit F: Dns[F]): F.type = F } diff --git a/test-kit/shared/src/test/scala/com/comcast/ip4s/DnsTest.scala b/test-kit/shared/src/test/scala/com/comcast/ip4s/DnsTest.scala new file mode 100644 index 00000000..e4160f01 --- /dev/null +++ b/test-kit/shared/src/test/scala/com/comcast/ip4s/DnsTest.scala @@ -0,0 +1,49 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * 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 com.comcast.ip4s + +import cats.effect.IO +import cats.syntax.all._ +import munit.CatsEffectSuite + +class DnsTest extends CatsEffectSuite { + + test("resolve/reverseAll round-trip") { + for { + hostname <- Hostname.fromString("comcast.com").liftTo[IO](new NoSuchElementException) + address <- Dns[IO].resolve(hostname) + hostnames <- Dns[IO].reverseAll(address) + _ <- IO(assert(hostnames.nonEmpty)) + reversedAddress <- hostnames.traverse(Dns[IO].resolve) + } yield assert(reversedAddress.forall(_ == address)) + } + + test("resolveAll/reverse round-trip") { + for { + hostname <- Hostname.fromString("comcast.com").liftTo[IO](new NoSuchElementException) + addresses <- Dns[IO].resolveAll(hostname) + _ <- IO(assert(addresses.nonEmpty)) + hostnames <- addresses.traverse(Dns[IO].reverse) + reversedAddresses <- hostnames.traverse(Dns[IO].resolve) + } yield assertEquals(Set(addresses), Set(reversedAddresses)) + } + + test("loopback") { + assertIO(Dns[IO].loopback.map(_.some), IpAddress.fromString("127.0.0.1")) + } + +} From e159a44b9d640d7a47a9fa07cb1746ddf3276e08 Mon Sep 17 00:00:00 2001 From: Arman Bilge Date: Sun, 3 Oct 2021 19:15:15 +0000 Subject: [PATCH 2/4] Privatize DnsCompanionPlatform --- js/src/main/scala/com/comcast/ip4s/DnsPlatform.scala | 2 +- jvm/src/main/scala/com/comcast/ip4s/DnsPlatform.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/js/src/main/scala/com/comcast/ip4s/DnsPlatform.scala b/js/src/main/scala/com/comcast/ip4s/DnsPlatform.scala index 3b8642cc..ebc76f0c 100644 --- a/js/src/main/scala/com/comcast/ip4s/DnsPlatform.scala +++ b/js/src/main/scala/com/comcast/ip4s/DnsPlatform.scala @@ -23,7 +23,7 @@ import scala.scalajs.js import scala.scalajs.js.| import scala.scalajs.js.annotation.JSImport -trait DnsCompanionPlatform { +private[ip4s] trait DnsCompanionPlatform { implicit def forAsync[F[_]](implicit F: Async[F]): Dns[F] = new UnsealedDns[F] { def resolve(hostname: Hostname): F[IpAddress] = F.fromPromise(F.delay(dnsPromises.lookup(hostname.toString, LookupOptions(all = false)))) diff --git a/jvm/src/main/scala/com/comcast/ip4s/DnsPlatform.scala b/jvm/src/main/scala/com/comcast/ip4s/DnsPlatform.scala index d4a6262b..62574cca 100644 --- a/jvm/src/main/scala/com/comcast/ip4s/DnsPlatform.scala +++ b/jvm/src/main/scala/com/comcast/ip4s/DnsPlatform.scala @@ -21,7 +21,7 @@ import cats.syntax.all._ import java.net.InetAddress -trait DnsCompanionPlatform { +private[ip4s] trait DnsCompanionPlatform { implicit def forSync[F[_]](implicit F: Sync[F]): Dns[F] = new UnsealedDns[F] { def resolve(hostname: Hostname): F[IpAddress] = F.blocking { From 44f8f6050b07d03e1de6840ab4a22b4169ef4b63 Mon Sep 17 00:00:00 2001 From: Arman Bilge Date: Sun, 3 Oct 2021 19:18:10 +0000 Subject: [PATCH 3/4] Fix reverse scaladocs --- shared/src/main/scala/com/comcast/ip4s/Dns.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/src/main/scala/com/comcast/ip4s/Dns.scala b/shared/src/main/scala/com/comcast/ip4s/Dns.scala index de8ec6fd..f8d58236 100644 --- a/shared/src/main/scala/com/comcast/ip4s/Dns.scala +++ b/shared/src/main/scala/com/comcast/ip4s/Dns.scala @@ -48,13 +48,13 @@ sealed trait Dns[F[_]] { /** Reverses the supplied address to a hostname using the platform DNS resolver. * - * If the address cannot be resolved, a `None` is returned. + * If the address cannot be reversed, a `None` is returned. */ def reverseOption(address: IpAddress): F[Option[Hostname]] /** Reverses the supplied address to all hostnames known to the platform DNS resolver. * - * If the address cannot be resolved, an empty list is returned. + * If the address cannot be reversed, an empty list is returned. */ def reverseAll(address: IpAddress): F[List[Hostname]] From 6e494f97f258ac83806c28e662e853f8804180e7 Mon Sep 17 00:00:00 2001 From: Arman Bilge Date: Sun, 3 Oct 2021 19:21:27 +0000 Subject: [PATCH 4/4] Hush mima --- build.sbt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 302da24a..ee963295 100644 --- a/build.sbt +++ b/build.sbt @@ -46,7 +46,8 @@ ThisBuild / initialCommands := "import com.comcast.ip4s._" ThisBuild / fatalWarningsInCI := false ThisBuild / mimaBinaryIssueFilters ++= Seq( - ProblemFilters.exclude[DirectMissingMethodProblem]("com.comcast.ip4s.Ipv6Address.toInetAddress") + ProblemFilters.exclude[DirectMissingMethodProblem]("com.comcast.ip4s.Ipv6Address.toInetAddress"), + ProblemFilters.exclude[ReversedMissingMethodProblem]("com.comcast.ip4s.Dns.*") // sealed trait ) lazy val root = project