From 4d1197099ce4e721c18bf4cacbb2e1980e4210b5 Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Wed, 12 Sep 2018 16:40:57 -0700 Subject: Move REST functionality to separate project --- .../src/main/scala/xyz/driver/core/json.scala | 398 +++++++++++++++++++++ 1 file changed, 398 insertions(+) create mode 100644 core-rest/src/main/scala/xyz/driver/core/json.scala (limited to 'core-rest/src/main/scala/xyz/driver/core/json.scala') diff --git a/core-rest/src/main/scala/xyz/driver/core/json.scala b/core-rest/src/main/scala/xyz/driver/core/json.scala new file mode 100644 index 0000000..edc2347 --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/json.scala @@ -0,0 +1,398 @@ +package xyz.driver.core + +import java.net.InetAddress +import java.time.format.DateTimeFormatter +import java.time.{Instant, LocalDate} +import java.util.{TimeZone, UUID} + +import akka.http.scaladsl.unmarshalling.Unmarshaller +import com.neovisionaries.i18n.{CountryCode, CurrencyCode} +import enumeratum._ +import eu.timepit.refined.api.{Refined, Validate} +import eu.timepit.refined.collection.NonEmpty +import eu.timepit.refined.refineV +import spray.json._ +import xyz.driver.core.auth.AuthCredentials +import xyz.driver.core.date.{Date, DayOfWeek, Month} +import xyz.driver.core.domain.{Email, PhoneNumber} +import xyz.driver.core.rest.directives.{PathMatchers, Unmarshallers} +import xyz.driver.core.rest.errors._ +import xyz.driver.core.time.{Time, TimeOfDay} + +import scala.reflect.runtime.universe._ +import scala.reflect.{ClassTag, classTag} +import scala.util.Try +import scala.util.control.NonFatal + +object json extends PathMatchers with Unmarshallers { + import DefaultJsonProtocol._ + + implicit def idFormat[T]: RootJsonFormat[Id[T]] = new RootJsonFormat[Id[T]] { + def write(id: Id[T]) = JsString(id.value) + + def read(value: JsValue): Id[T] = value match { + case JsString(id) if Try(UUID.fromString(id)).isSuccess => Id[T](id.toLowerCase) + case JsString(id) => Id[T](id) + case _ => throw DeserializationException("Id expects string") + } + } + + implicit def taggedFormat[F, T](implicit underlying: JsonFormat[F], convert: F => F @@ T = null): JsonFormat[F @@ T] = + new JsonFormat[F @@ T] { + import tagging._ + + private val transformReadValue = Option(convert).getOrElse((_: F).tagged[T]) + + override def write(obj: F @@ T): JsValue = underlying.write(obj) + + override def read(json: JsValue): F @@ T = transformReadValue(underlying.read(json)) + } + + implicit def nameFormat[T] = new RootJsonFormat[Name[T]] { + def write(name: Name[T]) = JsString(name.value) + + def read(value: JsValue): Name[T] = value match { + case JsString(name) => Name[T](name) + case _ => throw DeserializationException("Name expects string") + } + } + + implicit val timeFormat: RootJsonFormat[Time] = new RootJsonFormat[Time] { + def write(time: Time) = JsObject("timestamp" -> JsNumber(time.millis)) + + def read(value: JsValue): Time = Time(instantFormat.read(value)) + } + + implicit val instantFormat: JsonFormat[Instant] = new JsonFormat[Instant] { + def write(instant: Instant): JsValue = JsString(instant.toString) + + def read(value: JsValue): Instant = value match { + case JsObject(fields) => + fields + .get("timestamp") + .flatMap { + case JsNumber(millis) => Some(Instant.ofEpochMilli(millis.longValue())) + case _ => None + } + .getOrElse(deserializationError(s"Instant expects ISO timestamp but got ${value.compactPrint}")) + case JsNumber(millis) => Instant.ofEpochMilli(millis.longValue()) + case JsString(str) => + try Instant.parse(str) + catch { case NonFatal(_) => deserializationError(s"Instant expects ISO timestamp but got $str") } + case _ => deserializationError(s"Instant expects ISO timestamp but got ${value.compactPrint}") + } + } + + implicit object localTimeFormat extends JsonFormat[java.time.LocalTime] { + private val formatter = TimeOfDay.getFormatter + def read(json: JsValue): java.time.LocalTime = json match { + case JsString(chars) => + java.time.LocalTime.parse(chars) + case _ => deserializationError(s"Expected time string got ${json.toString}") + } + + def write(obj: java.time.LocalTime): JsValue = { + JsString(obj.format(formatter)) + } + } + + implicit object timeZoneFormat extends JsonFormat[java.util.TimeZone] { + override def write(obj: TimeZone): JsValue = { + JsString(obj.getID()) + } + + override def read(json: JsValue): TimeZone = json match { + case JsString(chars) => + java.util.TimeZone.getTimeZone(chars) + case _ => deserializationError(s"Expected time zone string got ${json.toString}") + } + } + + implicit val timeOfDayFormat: RootJsonFormat[TimeOfDay] = jsonFormat2(TimeOfDay.apply) + + implicit val dayOfWeekFormat: JsonFormat[DayOfWeek] = new enumeratum.EnumJsonFormat(DayOfWeek) + + implicit val dateFormat = new RootJsonFormat[Date] { + def write(date: Date) = JsString(date.toString) + def read(value: JsValue): Date = value match { + case JsString(dateString) => + Date + .fromString(dateString) + .getOrElse( + throw DeserializationException(s"Misformated ISO 8601 Date. Expected YYYY-MM-DD, but got $dateString.")) + case _ => throw DeserializationException(s"Date expects a string, but got $value.") + } + } + + implicit val localDateFormat = new RootJsonFormat[LocalDate] { + val format = DateTimeFormatter.ISO_LOCAL_DATE + + def write(date: LocalDate): JsValue = JsString(date.format(format)) + def read(value: JsValue): LocalDate = value match { + case JsString(dateString) => + try LocalDate.parse(dateString, format) + catch { + case NonFatal(_) => + throw deserializationError(s"Malformed ISO 8601 Date. Expected YYYY-MM-DD, but got $dateString.") + } + case _ => + throw deserializationError(s"Malformed ISO 8601 Date. Expected YYYY-MM-DD, but got ${value.compactPrint}.") + } + } + + implicit val monthFormat = new RootJsonFormat[Month] { + def write(month: Month) = JsNumber(month) + def read(value: JsValue): Month = value match { + case JsNumber(month) if 0 <= month && month <= 11 => Month(month.toInt) + case _ => throw DeserializationException("Expected a number from 0 to 11") + } + } + + implicit def revisionFormat[T]: RootJsonFormat[Revision[T]] = new RootJsonFormat[Revision[T]] { + def write(revision: Revision[T]) = JsString(revision.id.toString) + + def read(value: JsValue): Revision[T] = value match { + case JsString(revision) => Revision[T](revision) + case _ => throw DeserializationException("Revision expects uuid string") + } + } + + implicit val base64Format = new RootJsonFormat[Base64] { + def write(base64Value: Base64) = JsString(base64Value.value) + + def read(value: JsValue): Base64 = value match { + case JsString(base64Value) => Base64(base64Value) + case _ => throw DeserializationException("Base64 format expects string") + } + } + + implicit val emailFormat = new RootJsonFormat[Email] { + def write(email: Email) = JsString(email.username + "@" + email.domain) + def read(json: JsValue): Email = json match { + + case JsString(value) => + Email.parse(value).getOrElse { + deserializationError("Expected '@' symbol in email string as Email, but got " + json.toString) + } + + case _ => + deserializationError("Expected string as Email, but got " + json.toString) + } + } + + implicit object phoneNumberFormat extends RootJsonFormat[PhoneNumber] { + + 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")) + } + } + + implicit val authCredentialsFormat = new RootJsonFormat[AuthCredentials] { + override def read(json: JsValue): AuthCredentials = { + json match { + case JsObject(fields) => + val emailField = fields.get("email") + val identifierField = fields.get("identifier") + val passwordField = fields.get("password") + + (emailField, identifierField, passwordField) match { + case (_, _, None) => + deserializationError("password field must be set") + case (Some(JsString(em)), _, Some(JsString(pw))) => + val email = Email.parse(em).getOrElse(throw deserializationError(s"failed to parse email $em")) + AuthCredentials(email.toString, pw) + case (_, Some(JsString(id)), Some(JsString(pw))) => AuthCredentials(id.toString, pw.toString) + case (None, None, _) => deserializationError("identifier must be provided") + case _ => deserializationError(s"failed to deserialize ${json.prettyPrint}") + } + case _ => deserializationError(s"failed to deserialize ${json.prettyPrint}") + } + } + + override def write(obj: AuthCredentials): JsValue = JsObject( + "identifier" -> JsString(obj.identifier), + "password" -> JsString(obj.password) + ) + } + + implicit object inetAddressFormat extends JsonFormat[InetAddress] { + override def read(json: JsValue): InetAddress = json match { + case JsString(ipString) => + Try(InetAddress.getByName(ipString)) + .getOrElse(deserializationError(s"Invalid IP Address: $ipString")) + case _ => deserializationError(s"Expected string for IP Address, got $json") + } + + override def write(obj: InetAddress): JsValue = + JsString(obj.getHostAddress) + } + + implicit val countryCodeFormat: JsonFormat[CountryCode] = javaEnumFormat[CountryCode] + + implicit val currencyCodeFormat: JsonFormat[CurrencyCode] = javaEnumFormat[CurrencyCode] + + object enumeratum { + + def enumUnmarshaller[T <: EnumEntry](enum: Enum[T]): Unmarshaller[String, T] = + Unmarshaller.strict { value => + enum.withNameOption(value).getOrElse(unrecognizedValue(value, enum.values)) + } + + trait HasJsonFormat[T <: EnumEntry] { enum: Enum[T] => + + implicit val format: JsonFormat[T] = new EnumJsonFormat(enum) + + implicit val unmarshaller: Unmarshaller[String, T] = + Unmarshaller.strict { value => + enum.withNameOption(value).getOrElse(unrecognizedValue(value, enum.values)) + } + } + + class EnumJsonFormat[T <: EnumEntry](enum: Enum[T]) extends JsonFormat[T] { + override def read(json: JsValue): T = json match { + case JsString(name) => enum.withNameOption(name).getOrElse(unrecognizedValue(name, enum.values)) + case _ => deserializationError("Expected string as enumeration value, but got " + json.toString) + } + + override def write(obj: T): JsValue = JsString(obj.entryName) + } + + private def unrecognizedValue(value: String, possibleValues: Seq[Any]): Nothing = + deserializationError(s"Unexpected value $value. Expected one of: ${possibleValues.mkString("[", ", ", "]")}") + } + + class EnumJsonFormat[T](mapping: (String, T)*) extends RootJsonFormat[T] { + private val map = mapping.toMap + + override def write(value: T): JsValue = { + map.find(_._2 == value).map(_._1) match { + case Some(name) => JsString(name) + case _ => serializationError(s"Value $value is not found in the mapping $map") + } + } + + override def read(json: JsValue): T = json match { + case JsString(name) => + map.getOrElse(name, throw DeserializationException(s"Value $name is not found in the mapping $map")) + case _ => deserializationError("Expected string as enumeration value, but got " + json.toString) + } + } + + def javaEnumFormat[T <: java.lang.Enum[_]: ClassTag]: JsonFormat[T] = { + val values = classTag[T].runtimeClass.asInstanceOf[Class[T]].getEnumConstants + new EnumJsonFormat[T](values.map(v => v.name() -> v): _*) + } + + class ValueClassFormat[T: TypeTag](writeValue: T => BigDecimal, create: BigDecimal => T) extends JsonFormat[T] { + def write(valueClass: T) = JsNumber(writeValue(valueClass)) + def read(json: JsValue): T = json match { + case JsNumber(value) => create(value) + case _ => deserializationError(s"Expected number as ${typeOf[T].getClass.getName}, but got " + json.toString) + } + } + + class GadtJsonFormat[T: TypeTag]( + typeField: String, + typeValue: PartialFunction[T, String], + jsonFormat: PartialFunction[String, JsonFormat[_ <: T]]) + extends RootJsonFormat[T] { + + def write(value: T): JsValue = { + + val valueType = typeValue.applyOrElse(value, { v: T => + deserializationError(s"No Value type for this type of ${typeOf[T].getClass.getName}: " + v.toString) + }) + + val valueFormat = + jsonFormat.applyOrElse(valueType, { f: String => + deserializationError(s"No Json format for this type of $valueType") + }) + + valueFormat.asInstanceOf[JsonFormat[T]].write(value) match { + case JsObject(fields) => JsObject(fields ++ Map(typeField -> JsString(valueType))) + case _ => serializationError(s"${typeOf[T].getClass.getName} serialized not to a JSON object") + } + } + + def read(json: JsValue): T = json match { + case JsObject(fields) => + val valueJson = JsObject(fields.filterNot(_._1 == typeField)) + fields(typeField) match { + case JsString(valueType) => + val valueFormat = jsonFormat.applyOrElse(valueType, { t: String => + deserializationError(s"Unknown ${typeOf[T].getClass.getName} type ${fields(typeField)}") + }) + valueFormat.read(valueJson) + case _ => + deserializationError(s"Unknown ${typeOf[T].getClass.getName} type ${fields(typeField)}") + } + case _ => + deserializationError(s"Expected Json Object as ${typeOf[T].getClass.getName}, but got " + json.toString) + } + } + + object GadtJsonFormat { + + def create[T: TypeTag](typeField: String)(typeValue: PartialFunction[T, String])( + jsonFormat: PartialFunction[String, JsonFormat[_ <: T]]) = { + + new GadtJsonFormat[T](typeField, typeValue, jsonFormat) + } + } + + /** + * Provides the JsonFormat for the Refined types provided by the Refined library. + * + * @see https://github.com/fthomas/refined + */ + implicit def refinedJsonFormat[T, Predicate]( + implicit valueFormat: JsonFormat[T], + validate: Validate[T, Predicate]): JsonFormat[Refined[T, Predicate]] = + new JsonFormat[Refined[T, Predicate]] { + def write(x: T Refined Predicate): JsValue = valueFormat.write(x.value) + def read(value: JsValue): T Refined Predicate = { + refineV[Predicate](valueFormat.read(value))(validate) match { + case Right(refinedValue) => refinedValue + case Left(refinementError) => deserializationError(refinementError) + } + } + } + + implicit def nonEmptyNameFormat[T](implicit nonEmptyStringFormat: JsonFormat[Refined[String, NonEmpty]]) = + new RootJsonFormat[NonEmptyName[T]] { + def write(name: NonEmptyName[T]) = JsString(name.value.value) + + def read(value: JsValue): NonEmptyName[T] = + NonEmptyName[T](nonEmptyStringFormat.read(value)) + } + + implicit val serviceExceptionFormat: RootJsonFormat[ServiceException] = + GadtJsonFormat.create[ServiceException]("type") { + case _: InvalidInputException => "InvalidInputException" + case _: InvalidActionException => "InvalidActionException" + case _: UnauthorizedException => "UnauthorizedException" + case _: ResourceNotFoundException => "ResourceNotFoundException" + case _: ExternalServiceException => "ExternalServiceException" + case _: ExternalServiceTimeoutException => "ExternalServiceTimeoutException" + case _: DatabaseException => "DatabaseException" + } { + case "InvalidInputException" => jsonFormat(InvalidInputException, "message") + case "InvalidActionException" => jsonFormat(InvalidActionException, "message") + case "UnauthorizedException" => jsonFormat(UnauthorizedException, "message") + case "ResourceNotFoundException" => jsonFormat(ResourceNotFoundException, "message") + case "ExternalServiceException" => + jsonFormat(ExternalServiceException, "serviceName", "serviceMessage", "serviceException") + case "ExternalServiceTimeoutException" => jsonFormat(ExternalServiceTimeoutException, "message") + case "DatabaseException" => jsonFormat(DatabaseException, "message") + } + +} -- cgit v1.2.3