From 1b979318d85ea6035084253596cf076151cef309 Mon Sep 17 00:00:00 2001 From: Sergey Nastich Date: Wed, 19 Sep 2018 13:57:53 -0400 Subject: Improve PhoneNumber (#222) * Add support for extensions * Add PathMatcher and allow parsing JSON from string * Add a number of convenience methods which are to be used instead of `toString` --- src/main/scala/xyz/driver/core/domain.scala | 45 +++++++++++++++++++--- src/main/scala/xyz/driver/core/json.scala | 16 ++++++-- .../driver/core/rest/directives/PathMatchers.scala | 12 ++++++ src/test/scala/xyz/driver/core/JsonTest.scala | 15 ++++++++ .../scala/xyz/driver/core/PhoneNumberTest.scala | 38 ++++++++++++++++++ 5 files changed, 116 insertions(+), 10 deletions(-) diff --git a/src/main/scala/xyz/driver/core/domain.scala b/src/main/scala/xyz/driver/core/domain.scala index 59bed54..f3b8337 100644 --- a/src/main/scala/xyz/driver/core/domain.scala +++ b/src/main/scala/xyz/driver/core/domain.scala @@ -1,13 +1,20 @@ package xyz.driver.core import com.google.i18n.phonenumbers.PhoneNumberUtil +import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat import scalaz.Equal import scalaz.std.string._ import scalaz.syntax.equal._ +import scala.util.Try +import scala.util.control.NonFatal + object domain { final case class Email(username: String, domain: String) { + + def value: String = toString + override def toString: String = username + "@" + domain } @@ -23,18 +30,44 @@ object domain { } } - final case class PhoneNumber(countryCode: String = "1", number: String) { - override def toString: String = s"+$countryCode $number" + final case class PhoneNumber(countryCode: String, number: String, extension: Option[String] = None) { + + def hasExtension: Boolean = extension.isDefined + + /** This is a more human-friendly alias for #toE164String() */ + def toCompactString: String = s"+$countryCode$number${extension.fold("")(";ext=" + _)}" + + /** Outputs the phone number in a E.164-compliant way, e.g. +14151234567 */ + def toE164String: String = toCompactString + + /** + * Outputs the phone number in a "readable" way, e.g. "+1 415-123-45-67 ext. 1234" + * @throws IllegalStateException if the contents of this object is not a valid phone number + */ + @throws[IllegalStateException] + def toHumanReadableString: String = + try { + val phoneNumber = PhoneNumber.phoneUtil.parse(toE164String, "US") + PhoneNumber.phoneUtil.format(phoneNumber, PhoneNumberFormat.INTERNATIONAL) + } catch { + case NonFatal(e) => throw new IllegalStateException(s"$toString is not a valid number", e) + } + + override def toString: String = s"+$countryCode $number${extension.fold("")(" ext. " + _)}" } object PhoneNumber { - private val phoneUtil = PhoneNumberUtil.getInstance() + private[PhoneNumber] val phoneUtil = PhoneNumberUtil.getInstance() def parse(phoneNumber: String): Option[PhoneNumber] = { - val validated = - util.Try(phoneUtil.parseAndKeepRawInput(phoneNumber, "US")).toOption.filter(phoneUtil.isValidNumber) - validated.map(pn => PhoneNumber(pn.getCountryCode.toString, pn.getNationalNumber.toString)) + val validated = Try(phoneUtil.parseAndKeepRawInput(phoneNumber, "US")).toOption.filter(phoneUtil.isValidNumber) + validated.map { pn => + PhoneNumber( + pn.getCountryCode.toString, + pn.getNationalNumber.toString, + Option(pn.getExtension).filter(_.nonEmpty)) + } } } } diff --git a/src/main/scala/xyz/driver/core/json.scala b/src/main/scala/xyz/driver/core/json.scala index 4daf127..edc2347 100644 --- a/src/main/scala/xyz/driver/core/json.scala +++ b/src/main/scala/xyz/driver/core/json.scala @@ -181,10 +181,18 @@ object json extends PathMatchers with Unmarshallers { } implicit object phoneNumberFormat extends RootJsonFormat[PhoneNumber] { - private val basicFormat = jsonFormat2(PhoneNumber.apply) - override def write(obj: PhoneNumber): JsValue = basicFormat.write(obj) - override def read(json: JsValue): PhoneNumber = { - PhoneNumber.parse(basicFormat.read(json).toString).getOrElse(deserializationError("Invalid phone number")) + + private val basicFormat = jsonFormat3(PhoneNumber.apply) + + def write(obj: PhoneNumber): JsValue = basicFormat.write(obj) + + def read(json: JsValue): PhoneNumber = { + val maybePhone = json match { + case JsString(number) => PhoneNumber.parse(number) + case obj: JsObject => PhoneNumber.parse(basicFormat.read(obj).toString) + case _ => None + } + maybePhone.getOrElse(deserializationError("Invalid phone number")) } } diff --git a/src/main/scala/xyz/driver/core/rest/directives/PathMatchers.scala b/src/main/scala/xyz/driver/core/rest/directives/PathMatchers.scala index 183ad9a..218c9ae 100644 --- a/src/main/scala/xyz/driver/core/rest/directives/PathMatchers.scala +++ b/src/main/scala/xyz/driver/core/rest/directives/PathMatchers.scala @@ -10,6 +10,7 @@ import akka.http.scaladsl.server.PathMatcher.{Matched, Unmatched} import akka.http.scaladsl.server.{PathMatcher, PathMatcher1, PathMatchers => AkkaPathMatchers} import eu.timepit.refined.collection.NonEmpty import eu.timepit.refined.refineV +import xyz.driver.core.domain.PhoneNumber import xyz.driver.core.time.Time import scala.util.control.NonFatal @@ -70,4 +71,15 @@ trait PathMatchers { Some(Revision[T](string)) } + def PhoneInPath: PathMatcher1[PhoneNumber] = new PathMatcher1[PhoneNumber] { + def apply(path: Path) = path match { + case Path.Segment(segment, tail) => + PhoneNumber + .parse(segment) + .map(parsed => Matched(tail, Tuple1(parsed))) + .getOrElse(Unmatched) + case _ => Unmatched + } + } + } diff --git a/src/test/scala/xyz/driver/core/JsonTest.scala b/src/test/scala/xyz/driver/core/JsonTest.scala index 2aa3572..fd693f9 100644 --- a/src/test/scala/xyz/driver/core/JsonTest.scala +++ b/src/test/scala/xyz/driver/core/JsonTest.scala @@ -247,6 +247,21 @@ class JsonTest extends WordSpec with Matchers with Inspectors { json.phoneNumberFormat.read(phoneJson) }.getMessage shouldBe "Invalid phone number" } + + "parse phone number from string" in { + JsString("+14243039608").convertTo[PhoneNumber] shouldBe PhoneNumber("1", "4243039608") + } + } + + "Path matcher for PhoneNumber" should { + "read valid phone number" in { + val string = "+14243039608x23" + val phone = PhoneNumber("1", "4243039608", Some("23")) + + val matcher = PathMatcher("foo") / PhoneInPath + + matcher(Uri.Path("foo") / string / "bar") shouldBe Matched(Uri.Path./("bar"), Tuple1(phone)) + } } "Json format for ADT mappings" should { diff --git a/src/test/scala/xyz/driver/core/PhoneNumberTest.scala b/src/test/scala/xyz/driver/core/PhoneNumberTest.scala index 384c7be..729302b 100644 --- a/src/test/scala/xyz/driver/core/PhoneNumberTest.scala +++ b/src/test/scala/xyz/driver/core/PhoneNumberTest.scala @@ -44,6 +44,21 @@ class PhoneNumberTest extends FlatSpec with Matchers { PhoneNumber.parse("+86 134 52 52 2256") shouldBe Some(PhoneNumber("86", "13452522256")) } + it should "parse numbers with extensions in different formats" in { + // format: off + val numbers = List( + "+1 800 525 22 25 x23", + "+18005252225 ext. 23", + "+18005252225,23" + ) + // format: on + + val parsed = numbers.flatMap(PhoneNumber.parse) + + parsed should have size numbers.size + parsed should contain only PhoneNumber("1", "8005252225", Some("23")) + } + it should "return None on numbers that are shorter than the minimum number of digits for the country (i.e. US - 10, AR - 11)" in { withClue("US and CN numbers are 10 digits - 9 digit (and shorter) numbers should not fit") { // format: off @@ -76,4 +91,27 @@ class PhoneNumberTest extends FlatSpec with Matchers { List(PhoneNumber("45", "27452522"), PhoneNumber("86", "13452522256")) } + "PhoneNumber.toCompactString/toE164String" should "produce phone number in international format without whitespaces" in { + PhoneNumber.parse("+1 800 5252225").get.toCompactString shouldBe "+18005252225" + PhoneNumber.parse("+1 800 5252225").get.toE164String shouldBe "+18005252225" + + PhoneNumber.parse("+1 800 5252225 x23").get.toCompactString shouldBe "+18005252225;ext=23" + PhoneNumber.parse("+1 800 5252225 x23").get.toE164String shouldBe "+18005252225;ext=23" + } + + "PhoneNumber.toHumanReadableString" should "produce nice readable result for different countries" in { + PhoneNumber.parse("+14154234567").get.toHumanReadableString shouldBe "+1 415-423-4567" + PhoneNumber.parse("+14154234567,23").get.toHumanReadableString shouldBe "+1 415-423-4567 ext. 23" + + PhoneNumber.parse("+78005252225").get.toHumanReadableString shouldBe "+7 800 525-22-25" + + PhoneNumber.parse("+41219437898").get.toHumanReadableString shouldBe "+41 21 943 78 98" + } + + it should "throw an IllegalArgumentException if the PhoneNumber object is not parsable/valid" in { + intercept[IllegalStateException] { + PhoneNumber("+123", "1238123120938120938").toHumanReadableString + }.getMessage should include("+123 1238123120938120938 is not a valid number") + } + } -- cgit v1.2.3