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 From 48aa360ef281ab48e18c46cef966b5152105332a Mon Sep 17 00:00:00 2001 From: Sergey Nastich Date: Sat, 22 Sep 2018 01:00:03 -0400 Subject: Fix `responseToListResponse` for empty non-paginated responses (#225) --- src/main/scala/xyz/driver/core/rest/RestService.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/xyz/driver/core/rest/RestService.scala b/src/main/scala/xyz/driver/core/rest/RestService.scala index 91b3550..a324338 100644 --- a/src/main/scala/xyz/driver/core/rest/RestService.scala +++ b/src/main/scala/xyz/driver/core/rest/RestService.scala @@ -79,7 +79,7 @@ trait RestService extends Service { .find(_.name() === ContextHeaders.ResourceCount) .map(_.value().toInt) .getOrElse(0) - val meta = ListResponse.Meta(resourceCount, pagination.getOrElse(Pagination(resourceCount, 1))) + val meta = ListResponse.Meta(resourceCount, pagination.getOrElse(Pagination(resourceCount max 1, 1))) Unmarshal(response.entity).to[List[T]].map(ListResponse(_, meta)) } -- cgit v1.2.3 From 18ad50733ac937148cbbb72c73fb5477ea6b50a1 Mon Sep 17 00:00:00 2001 From: Sergey Nastich Date: Sun, 30 Sep 2018 16:40:47 -0400 Subject: Fix `responseToListResponse` for `x-resource-count` header in lowercase (#227) --- src/main/scala/xyz/driver/core/rest/RestService.scala | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/scala/xyz/driver/core/rest/RestService.scala b/src/main/scala/xyz/driver/core/rest/RestService.scala index a324338..09d98b8 100644 --- a/src/main/scala/xyz/driver/core/rest/RestService.scala +++ b/src/main/scala/xyz/driver/core/rest/RestService.scala @@ -6,8 +6,6 @@ import akka.stream.Materializer import scala.concurrent.{ExecutionContext, Future} import scalaz.{ListT, OptionT} -import scalaz.syntax.equal._ -import scalaz.Scalaz.stringInstance trait RestService extends Service { @@ -76,7 +74,7 @@ trait RestService extends Service { response: HttpResponse): Future[ListResponse[T]] = { import DefaultJsonProtocol._ val resourceCount = response.headers - .find(_.name() === ContextHeaders.ResourceCount) + .find(_.name() equalsIgnoreCase ContextHeaders.ResourceCount) .map(_.value().toInt) .getOrElse(0) val meta = ListResponse.Meta(resourceCount, pagination.getOrElse(Pagination(resourceCount max 1, 1))) -- cgit v1.2.3 From 2cef01adfe3ebd3a0fa1e0bbbba7f6388198ba10 Mon Sep 17 00:00:00 2001 From: Zach Smith Date: Tue, 2 Oct 2018 16:53:19 -0700 Subject: Fixes for Alicloud pubsub (#230) * Remove recover from processMessage in StreatBus * Check if queue exists before creating --- .../xyz/driver/core/messaging/AliyunBus.scala | 30 ++++++++++++---------- .../xyz/driver/core/messaging/StreamBus.scala | 2 +- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/main/scala/xyz/driver/core/messaging/AliyunBus.scala b/src/main/scala/xyz/driver/core/messaging/AliyunBus.scala index c23ea0f..8b7bca7 100644 --- a/src/main/scala/xyz/driver/core/messaging/AliyunBus.scala +++ b/src/main/scala/xyz/driver/core/messaging/AliyunBus.scala @@ -136,18 +136,22 @@ class AliyunBus( def createSubscription(topic: Topic[_], config: SubscriptionConfig): Future[Unit] = Future { val subscriptionName = rawSubscriptionName(config, topic) - val topicName = rawTopicName(topic) - val topicRef = client.getTopicRef(topicName) - - val queueMeta = new QueueMeta - queueMeta.setQueueName(subscriptionName) - queueMeta.setVisibilityTimeout(config.ackTimeout.toSeconds) - client.createQueue(queueMeta) - - val subscriptionMeta = new SubscriptionMeta - subscriptionMeta.setSubscriptionName(subscriptionName) - subscriptionMeta.setTopicName(topicName) - subscriptionMeta.setEndpoint(topicRef.generateQueueEndpoint(subscriptionName)) - topicRef.subscribe(subscriptionMeta) + val queueExists = Option(client.listQueue(subscriptionName, "", 1)).exists(!_.getResult.isEmpty) + + if (!queueExists) { + val topicName = rawTopicName(topic) + val topicRef = client.getTopicRef(topicName) + + val queueMeta = new QueueMeta + queueMeta.setQueueName(subscriptionName) + queueMeta.setVisibilityTimeout(config.ackTimeout.toSeconds) + client.createQueue(queueMeta) + + val subscriptionMeta = new SubscriptionMeta + subscriptionMeta.setSubscriptionName(subscriptionName) + subscriptionMeta.setTopicName(topicName) + subscriptionMeta.setEndpoint(topicRef.generateQueueEndpoint(subscriptionName)) + topicRef.subscribe(subscriptionMeta) + } } } diff --git a/src/main/scala/xyz/driver/core/messaging/StreamBus.scala b/src/main/scala/xyz/driver/core/messaging/StreamBus.scala index a9ba3a7..44d75cd 100644 --- a/src/main/scala/xyz/driver/core/messaging/StreamBus.scala +++ b/src/main/scala/xyz/driver/core/messaging/StreamBus.scala @@ -76,7 +76,7 @@ trait StreamBus extends Bus { maxRestarts ) { () => subscribe(topic, config) - .via(processMessage.recover({ case _ => Nil })) + .via(processMessage) .log(topic.name) .mapConcat(identity) } -- cgit v1.2.3 From 7a793ffa068fda8f2146f84fa785328d928dba03 Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Wed, 12 Sep 2018 15:56:41 -0700 Subject: Move core types into core-types project Note that xyz.driver.core.FutureExtensions was moved to xyz.driver.core.rest as it (only) contained logic that dealt with service exceptions, something that belongs into core-rest and must not be depended upon by core-types. This is a breaking change. --- build.sbt | 46 ++++- .../src/main/scala/xyz/driver/core/core.scala | 114 +++++++++++ .../src/main/scala/xyz/driver/core/date.scala | 109 +++++++++++ .../src/main/scala/xyz/driver/core/domain.scala | 73 +++++++ .../scala/xyz/driver/core/tagging/tagging.scala | 62 ++++++ .../src/main/scala/xyz/driver/core/time.scala | 209 +++++++++++++++++++++ .../src/test/scala/xyz/driver/core/CoreTest.scala | 88 +++++++++ .../src/test/scala/xyz/driver/core/DateTest.scala | 53 ++++++ .../scala/xyz/driver/core/PhoneNumberTest.scala | 117 ++++++++++++ .../src/test/scala/xyz/driver/core/TimeTest.scala | 143 ++++++++++++++ .../xyz/driver/core/tagging/TaggingTest.scala | 63 +++++++ src/main/scala/xyz/driver/core/core.scala | 124 ------------ src/main/scala/xyz/driver/core/date.scala | 109 ----------- src/main/scala/xyz/driver/core/domain.scala | 73 ------- src/main/scala/xyz/driver/core/rest/package.scala | 12 +- .../scala/xyz/driver/core/tagging/tagging.scala | 62 ------ src/main/scala/xyz/driver/core/time.scala | 209 --------------------- src/test/scala/xyz/driver/core/CoreTest.scala | 88 --------- src/test/scala/xyz/driver/core/DateTest.scala | 53 ------ .../scala/xyz/driver/core/PhoneNumberTest.scala | 117 ------------ src/test/scala/xyz/driver/core/TimeTest.scala | 143 -------------- .../xyz/driver/core/rest/DriverRouteTest.scala | 1 - .../xyz/driver/core/tagging/TaggingTest.scala | 63 ------- 23 files changed, 1082 insertions(+), 1049 deletions(-) create mode 100644 core-types/src/main/scala/xyz/driver/core/core.scala create mode 100644 core-types/src/main/scala/xyz/driver/core/date.scala create mode 100644 core-types/src/main/scala/xyz/driver/core/domain.scala create mode 100644 core-types/src/main/scala/xyz/driver/core/tagging/tagging.scala create mode 100644 core-types/src/main/scala/xyz/driver/core/time.scala create mode 100644 core-types/src/test/scala/xyz/driver/core/CoreTest.scala create mode 100644 core-types/src/test/scala/xyz/driver/core/DateTest.scala create mode 100644 core-types/src/test/scala/xyz/driver/core/PhoneNumberTest.scala create mode 100644 core-types/src/test/scala/xyz/driver/core/TimeTest.scala create mode 100644 core-types/src/test/scala/xyz/driver/core/tagging/TaggingTest.scala delete mode 100644 src/main/scala/xyz/driver/core/core.scala delete mode 100644 src/main/scala/xyz/driver/core/date.scala delete mode 100644 src/main/scala/xyz/driver/core/domain.scala delete mode 100644 src/main/scala/xyz/driver/core/tagging/tagging.scala delete mode 100644 src/main/scala/xyz/driver/core/time.scala delete mode 100644 src/test/scala/xyz/driver/core/CoreTest.scala delete mode 100644 src/test/scala/xyz/driver/core/DateTest.scala delete mode 100644 src/test/scala/xyz/driver/core/PhoneNumberTest.scala delete mode 100644 src/test/scala/xyz/driver/core/TimeTest.scala delete mode 100644 src/test/scala/xyz/driver/core/tagging/TaggingTest.scala diff --git a/build.sbt b/build.sbt index 030b12f..576de7b 100644 --- a/build.sbt +++ b/build.sbt @@ -1,8 +1,12 @@ import sbt._ import Keys._ -lazy val core = project - .in(file(".")) +val testdeps = libraryDependencies ++= Seq( + "org.mockito" % "mockito-core" % "1.9.5" % "test", + "org.scalacheck" %% "scalacheck" % "1.14.0" % "test", + "org.scalatest" %% "scalatest" % "3.0.5" % "test", +) +lazy val `core-util` = project .enablePlugins(LibraryPlugin) .settings( libraryDependencies ++= Seq( @@ -36,14 +40,42 @@ lazy val core = project "io.kamon" %% "kamon-statsd" % "1.0.0", "io.kamon" %% "kamon-system-metrics" % "1.0.0", "javax.xml.bind" % "jaxb-api" % "2.2.8", - "org.mockito" % "mockito-core" % "1.9.5" % "test", "org.scala-lang.modules" %% "scala-async" % "0.9.7", - "org.scalacheck" %% "scalacheck" % "1.14.0" % "test", - "org.scalatest" %% "scalatest" % "3.0.5" % "test", "org.scalaz" %% "scalaz-core" % "7.2.24", "xyz.driver" %% "spray-json-derivation" % "0.6.0", "xyz.driver" %% "tracing" % "0.1.2" - ), + ) + ) + +lazy val `core-types` = project + .enablePlugins(LibraryPlugin) + .dependsOn(`core-util`) + .settings(testdeps) + +lazy val `core-rest` = project + .enablePlugins(LibraryPlugin) + .dependsOn(`core-util`, `core-types`) + .settings(testdeps) + +lazy val `core-reporting` = project + .enablePlugins(LibraryPlugin) + .dependsOn(`core-util`) + .settings(testdeps) + +lazy val `core-cloud` = project + .enablePlugins(LibraryPlugin) + .dependsOn(`core-util`) + .settings(testdeps) + +lazy val `core-init` = project + .enablePlugins(LibraryPlugin) + .dependsOn(`core-util`) + .settings(testdeps) + +lazy val core = project + .in(file(".")) + .enablePlugins(LibraryPlugin) + .settings( scalacOptions in (Compile, doc) ++= Seq( "-groups", // group similar methods together based on the @group annotation. "-diagrams", // show classs hierarchy diagrams (requires 'dot' to be available on path) @@ -54,3 +86,5 @@ lazy val core = project s"https://github.com/drivergroup/driver-core/blob/master€{FILE_PATH}.scala" ) ) + .dependsOn(`core-types`, `core-rest`, `core-reporting`, `core-cloud`, `core-init`) + .settings(testdeps) diff --git a/core-types/src/main/scala/xyz/driver/core/core.scala b/core-types/src/main/scala/xyz/driver/core/core.scala new file mode 100644 index 0000000..67ac5ee --- /dev/null +++ b/core-types/src/main/scala/xyz/driver/core/core.scala @@ -0,0 +1,114 @@ +package xyz.driver + +import eu.timepit.refined.api.{Refined, Validate} +import eu.timepit.refined.collection.NonEmpty +import scalaz.{Equal, Monad, OptionT} +import xyz.driver.core.tagging.Tagged + +// TODO: this package seems too complex, look at all the features we need! +import scala.language.{higherKinds, implicitConversions, reflectiveCalls} + +package object core { + + def make[T](v: => T)(f: T => Unit): T = { + val value = v + f(value) + value + } + + def using[R <: { def close() }, P](r: => R)(f: R => P): P = { + val resource = r + try { + f(resource) + } finally { + resource.close() + } + } + + type @@[+V, +Tag] = V with Tagged[V, Tag] + + implicit class OptionTExtensions[H[_]: Monad, T](optionTValue: OptionT[H, T]) { + + def returnUnit: H[Unit] = optionTValue.fold[Unit](_ => (), ()) + + def continueIgnoringNone: OptionT[H, Unit] = + optionTValue.map(_ => ()).orElse(OptionT.some[H, Unit](())) + + def subflatMap[B](f: T => Option[B]): OptionT[H, B] = + OptionT.optionT[H](implicitly[Monad[H]].map(optionTValue.run)(_.flatMap(f))) + } + + implicit class MonadicExtensions[H[_]: Monad, T](monadicValue: H[T]) { + private implicit val monadT = implicitly[Monad[H]] + + def returnUnit: H[Unit] = monadT(monadicValue)(_ => ()) + + def toOptionT: OptionT[H, T] = + OptionT.optionT[H](monadT(monadicValue)(value => Option(value))) + + def toUnitOptionT: OptionT[H, Unit] = + OptionT.optionT[H](monadT(monadicValue)(_ => Option(()))) + } + +} + +package core { + + final case class Id[+Tag](value: String) extends AnyVal { + @inline def length: Int = value.length + override def toString: String = value + } + + @SuppressWarnings(Array("org.wartremover.warts.ImplicitConversion")) + object Id { + implicit def idEqual[T]: Equal[Id[T]] = Equal.equal[Id[T]](_ == _) + implicit def idOrdering[T]: Ordering[Id[T]] = Ordering.by[Id[T], String](_.value) + + sealed class Mapper[E, R] { + def apply[T >: E](id: Id[R]): Id[T] = Id[E](id.value) + def apply[T >: R](id: Id[E])(implicit dummy: DummyImplicit): Id[T] = Id[R](id.value) + } + object Mapper { + def apply[E, R] = new Mapper[E, R] + } + implicit def convertRE[R, E](id: Id[R])(implicit mapper: Mapper[E, R]): Id[E] = mapper[E](id) + implicit def convertER[E, R](id: Id[E])(implicit mapper: Mapper[E, R]): Id[R] = mapper[R](id) + } + + final case class Name[+Tag](value: String) extends AnyVal { + @inline def length: Int = value.length + override def toString: String = value + } + + object Name { + implicit def nameEqual[T]: Equal[Name[T]] = Equal.equal[Name[T]](_ == _) + implicit def nameOrdering[T]: Ordering[Name[T]] = Ordering.by(_.value) + + implicit def nameValidator[T, P](implicit stringValidate: Validate[String, P]): Validate[Name[T], P] = { + Validate.instance[Name[T], P, stringValidate.R]( + name => stringValidate.validate(name.value), + name => stringValidate.showExpr(name.value)) + } + } + + final case class NonEmptyName[+Tag](value: String Refined NonEmpty) { + @inline def length: Int = value.value.length + override def toString: String = value.value + } + + object NonEmptyName { + implicit def nonEmptyNameEqual[T]: Equal[NonEmptyName[T]] = + Equal.equal[NonEmptyName[T]](_.value.value == _.value.value) + + implicit def nonEmptyNameOrdering[T]: Ordering[NonEmptyName[T]] = Ordering.by(_.value.value) + } + + final case class Revision[T](id: String) + + object Revision { + implicit def revisionEqual[T]: Equal[Revision[T]] = Equal.equal[Revision[T]](_.id == _.id) + } + + final case class Base64(value: String) + +} diff --git a/core-types/src/main/scala/xyz/driver/core/date.scala b/core-types/src/main/scala/xyz/driver/core/date.scala new file mode 100644 index 0000000..5454093 --- /dev/null +++ b/core-types/src/main/scala/xyz/driver/core/date.scala @@ -0,0 +1,109 @@ +package xyz.driver.core + +import java.util.Calendar + +import enumeratum._ +import scalaz.std.anyVal._ +import scalaz.syntax.equal._ + +import scala.collection.immutable.IndexedSeq +import scala.util.Try + +/** + * Driver Date type and related validators/extractors. + * Day, Month, and Year extractors are from ISO 8601 strings => driver...Date integers. + * TODO: Decouple extractors from ISO 8601, as we might want to parse other formats. + */ +object date { + + sealed trait DayOfWeek extends EnumEntry + object DayOfWeek extends Enum[DayOfWeek] { + case object Monday extends DayOfWeek + case object Tuesday extends DayOfWeek + case object Wednesday extends DayOfWeek + case object Thursday extends DayOfWeek + case object Friday extends DayOfWeek + case object Saturday extends DayOfWeek + case object Sunday extends DayOfWeek + + val values: IndexedSeq[DayOfWeek] = findValues + + val All: Set[DayOfWeek] = values.toSet + + def fromString(day: String): Option[DayOfWeek] = withNameInsensitiveOption(day) + } + + type Day = Int @@ Day.type + + object Day { + def apply(value: Int): Day = { + require(1 to 31 contains value, "Day must be in range 1 <= value <= 31") + value.asInstanceOf[Day] + } + + def unapply(dayString: String): Option[Int] = { + require(dayString.length === 2, s"ISO 8601 day string, DD, must have length 2: $dayString") + Try(dayString.toInt).toOption.map(apply) + } + } + + type Month = Int @@ Month.type + + object Month { + def apply(value: Int): Month = { + require(0 to 11 contains value, "Month is zero-indexed: 0 <= value <= 11") + value.asInstanceOf[Month] + } + val JANUARY = Month(Calendar.JANUARY) + val FEBRUARY = Month(Calendar.FEBRUARY) + val MARCH = Month(Calendar.MARCH) + val APRIL = Month(Calendar.APRIL) + val MAY = Month(Calendar.MAY) + val JUNE = Month(Calendar.JUNE) + val JULY = Month(Calendar.JULY) + val AUGUST = Month(Calendar.AUGUST) + val SEPTEMBER = Month(Calendar.SEPTEMBER) + val OCTOBER = Month(Calendar.OCTOBER) + val NOVEMBER = Month(Calendar.NOVEMBER) + val DECEMBER = Month(Calendar.DECEMBER) + + def unapply(monthString: String): Option[Month] = { + require(monthString.length === 2, s"ISO 8601 month string, MM, must have length 2: $monthString") + Try(monthString.toInt).toOption.map(isoM => apply(isoM - 1)) + } + } + + type Year = Int @@ Year.type + + object Year { + def apply(value: Int): Year = value.asInstanceOf[Year] + + def unapply(yearString: String): Option[Int] = { + require(yearString.length === 4, s"ISO 8601 year string, YYYY, must have length 4: $yearString") + Try(yearString.toInt).toOption.map(apply) + } + } + + final case class Date(year: Int, month: Month, day: Int) { + override def toString = f"$year%04d-${month + 1}%02d-$day%02d" + } + + object Date { + implicit def dateOrdering: Ordering[Date] = Ordering.fromLessThan { (date1, date2) => + if (date1.year != date2.year) { + date1.year < date2.year + } else if (date1.month != date2.month) { + date1.month < date2.month + } else { + date1.day < date2.day + } + } + + def fromString(dateString: String): Option[Date] = { + dateString.split('-') match { + case Array(Year(year), Month(month), Day(day)) => Some(Date(year, month, day)) + case _ => None + } + } + } +} diff --git a/core-types/src/main/scala/xyz/driver/core/domain.scala b/core-types/src/main/scala/xyz/driver/core/domain.scala new file mode 100644 index 0000000..f3b8337 --- /dev/null +++ b/core-types/src/main/scala/xyz/driver/core/domain.scala @@ -0,0 +1,73 @@ +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 + } + + object Email { + implicit val emailEqual: Equal[Email] = Equal.equal { + case (left, right) => left.toString.toLowerCase === right.toString.toLowerCase + } + + def parse(emailString: String): Option[Email] = { + Some(emailString.split("@")) collect { + case Array(username, domain) => Email(username, domain) + } + } + } + + 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[PhoneNumber] val phoneUtil = PhoneNumberUtil.getInstance() + + def parse(phoneNumber: String): Option[PhoneNumber] = { + 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/core-types/src/main/scala/xyz/driver/core/tagging/tagging.scala b/core-types/src/main/scala/xyz/driver/core/tagging/tagging.scala new file mode 100644 index 0000000..5b6599e --- /dev/null +++ b/core-types/src/main/scala/xyz/driver/core/tagging/tagging.scala @@ -0,0 +1,62 @@ +package xyz.driver.core + +import scala.collection.generic.CanBuildFrom +import scala.language.{higherKinds, implicitConversions} + +/** + * @author sergey + * @since 9/11/18 + */ +package object tagging { + + implicit class Taggable[V <: Any](val v: V) extends AnyVal { + def tagged[Tag]: V @@ Tag = v.asInstanceOf[V @@ Tag] + } + +} + +package tagging { + + sealed trait Tagged[+V, +Tag] + + object Tagged { + implicit class TaggedOps[V, Tag](val v: V @@ Tag) extends AnyVal { + def tagless: V = v + } + + implicit def orderingMagnet[V, Tag](implicit ord: Ordering[V]): Ordering[V @@ Tag] = + ord.asInstanceOf[Ordering[V @@ Tag]] + + } + + sealed trait Trimmed + + object Trimmed { + + implicit def apply[V](trimmable: V)(implicit ev: CanBeTrimmed[V]): V @@ Trimmed = { + ev.trim(trimmable).tagged[Trimmed] + } + + sealed trait CanBeTrimmed[T] { + def trim(trimmable: T): T + } + + implicit object StringCanBeTrimmed extends CanBeTrimmed[String] { + def trim(str: String): String = str.trim() + } + + implicit def nameCanBeTrimmed[T]: CanBeTrimmed[Name[T]] = new CanBeTrimmed[Name[T]] { + def trim(name: Name[T]): Name[T] = Name[T](name.value.trim()) + } + + implicit def option2Trimmed[V: CanBeTrimmed](option: Option[V]): Option[V @@ Trimmed] = + option.map(Trimmed(_)) + + implicit def coll2Trimmed[T, C[_] <: Traversable[_]](coll: C[T])( + implicit ev: C[T] <:< Traversable[T], + tr: CanBeTrimmed[T], + bf: CanBuildFrom[Nothing, T @@ Trimmed, C[T @@ Trimmed]]): C[T @@ Trimmed] = + ev(coll).map(Trimmed(_)(tr)).to[C] + } + +} diff --git a/core-types/src/main/scala/xyz/driver/core/time.scala b/core-types/src/main/scala/xyz/driver/core/time.scala new file mode 100644 index 0000000..1622068 --- /dev/null +++ b/core-types/src/main/scala/xyz/driver/core/time.scala @@ -0,0 +1,209 @@ +package xyz.driver.core + +import java.text.SimpleDateFormat +import java.time.{Clock, Instant, ZoneId, ZoneOffset} +import java.util._ +import java.util.concurrent.TimeUnit + +import xyz.driver.core.date.Month + +import scala.concurrent.duration._ +import scala.language.implicitConversions +import scala.util.Try + +object time { + + // The most useful time units + val Second = 1000L + val Seconds = Second + val Minute = 60 * Seconds + val Minutes = Minute + val Hour = 60 * Minutes + val Hours = Hour + val Day = 24 * Hours + val Days = Day + val Week = 7 * Days + val Weeks = Week + + final case class Time(millis: Long) extends AnyVal { + + def isBefore(anotherTime: Time): Boolean = implicitly[Ordering[Time]].lt(this, anotherTime) + + def isAfter(anotherTime: Time): Boolean = implicitly[Ordering[Time]].gt(this, anotherTime) + + def advanceBy(duration: Duration): Time = Time(millis + duration.toMillis) + + def durationTo(anotherTime: Time): Duration = Duration.apply(anotherTime.millis - millis, TimeUnit.MILLISECONDS) + + def durationFrom(anotherTime: Time): Duration = Duration.apply(millis - anotherTime.millis, TimeUnit.MILLISECONDS) + + def toDate(timezone: TimeZone): date.Date = { + val cal = Calendar.getInstance(timezone) + cal.setTimeInMillis(millis) + date.Date(cal.get(Calendar.YEAR), date.Month(cal.get(Calendar.MONTH)), cal.get(Calendar.DAY_OF_MONTH)) + } + + def toInstant: Instant = Instant.ofEpochMilli(millis) + } + + object Time { + implicit def timeOrdering: Ordering[Time] = Ordering.by(_.millis) + + implicit def apply(instant: Instant): Time = Time(instant.toEpochMilli) + } + + /** + * Encapsulates a time and timezone without a specific date. + */ + final case class TimeOfDay(localTime: java.time.LocalTime, timeZone: TimeZone) { + + /** + * Is this time before another time on a specific day. Day light savings safe. These are zero-indexed + * for month/day. + */ + def isBefore(other: TimeOfDay, day: Int, month: Month, year: Int): Boolean = { + toCalendar(day, month, year).before(other.toCalendar(day, month, year)) + } + + /** + * Is this time after another time on a specific day. Day light savings safe. + */ + def isAfter(other: TimeOfDay, day: Int, month: Month, year: Int): Boolean = { + toCalendar(day, month, year).after(other.toCalendar(day, month, year)) + } + + def sameTimeAs(other: TimeOfDay, day: Int, month: Month, year: Int): Boolean = { + toCalendar(day, month, year).equals(other.toCalendar(day, month, year)) + } + + /** + * Enforces the same formatting as expected by [[java.sql.Time]] + * @return string formatted for `java.sql.Time` + */ + def timeString: String = { + localTime.format(TimeOfDay.getFormatter) + } + + /** + * @return a string parsable by [[java.util.TimeZone]] + */ + def timeZoneString: String = { + timeZone.getID + } + + /** + * @return this [[TimeOfDay]] as [[java.sql.Time]] object, [[java.sql.Time.valueOf]] will + * throw when the string is not valid, but this is protected by [[timeString]] method. + */ + def toTime: java.sql.Time = { + java.sql.Time.valueOf(timeString) + } + + private def toCalendar(day: Int, month: Int, year: Int): Calendar = { + val cal = Calendar.getInstance(timeZone) + cal.set(year, month, day, localTime.getHour, localTime.getMinute, localTime.getSecond) + cal.clear(Calendar.MILLISECOND) + cal + } + } + + object TimeOfDay { + def now(): TimeOfDay = { + TimeOfDay(java.time.LocalTime.now(), TimeZone.getDefault) + } + + /** + * Throws when [s] is not parsable by [[java.time.LocalTime.parse]], uses default [[java.util.TimeZone]] + */ + def parseTimeString(tz: TimeZone = TimeZone.getDefault)(s: String): TimeOfDay = { + TimeOfDay(java.time.LocalTime.parse(s), tz) + } + + def fromString(tz: TimeZone)(s: String): Option[TimeOfDay] = { + val op = Try(java.time.LocalTime.parse(s)).toOption + op.map(lt => TimeOfDay(lt, tz)) + } + + def fromStrings(zoneId: String)(s: String): Option[TimeOfDay] = { + val op = Try(TimeZone.getTimeZone(zoneId)).toOption + op.map(tz => TimeOfDay.parseTimeString(tz)(s)) + } + + /** + * Formatter that enforces `HH:mm:ss` which is expected by [[java.sql.Time]] + */ + def getFormatter: java.time.format.DateTimeFormatter = { + java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss") + } + } + + final case class TimeRange(start: Time, end: Time) { + def duration: Duration = FiniteDuration(end.millis - start.millis, MILLISECONDS) + } + + def startOfMonth(time: Time) = { + Time(make(new GregorianCalendar()) { cal => + cal.setTime(new Date(time.millis)) + cal.set(Calendar.DAY_OF_MONTH, cal.getActualMinimum(Calendar.DAY_OF_MONTH)) + }.getTime.getTime) + } + + def textualDate(timezone: TimeZone)(time: Time): String = + make(new SimpleDateFormat("MMMM d, yyyy"))(_.setTimeZone(timezone)).format(new Date(time.millis)) + + def textualTime(timezone: TimeZone)(time: Time): String = + make(new SimpleDateFormat("MMM dd, yyyy hh:mm:ss a"))(_.setTimeZone(timezone)).format(new Date(time.millis)) + + class ChangeableClock(@volatile var instant: Instant, val zone: ZoneId = ZoneOffset.UTC) extends Clock { + + def tick(duration: FiniteDuration): Unit = + instant = instant.plusNanos(duration.toNanos) + + val getZone: ZoneId = zone + + def withZone(zone: ZoneId): Clock = new ChangeableClock(instant, zone = zone) + + override def toString: String = "ChangeableClock(" + instant + "," + zone + ")" + } + + object provider { + + /** + * Time providers are supplying code with current times + * and are extremely useful for testing to check how system is going + * to behave at specific moments in time. + * + * All the calls to receive current time must be made using time + * provider injected to the caller. + */ + @deprecated( + "Use java.time.Clock instead. Note that xyz.driver.core.Time and xyz.driver.core.date.Date will also be deprecated soon!", + "0.13.0") + trait TimeProvider { + def currentTime(): Time + def toClock: Clock + } + + final implicit class ClockTimeProvider(clock: Clock) extends TimeProvider { + def currentTime(): Time = Time(clock.instant().toEpochMilli) + + val toClock: Clock = clock + } + + final class SystemTimeProvider extends TimeProvider { + def currentTime() = Time(System.currentTimeMillis()) + + lazy val toClock: Clock = Clock.systemUTC() + } + + final val SystemTimeProvider = new SystemTimeProvider + + final class SpecificTimeProvider(time: Time) extends TimeProvider { + + def currentTime(): Time = time + + lazy val toClock: Clock = Clock.fixed(time.toInstant, ZoneOffset.UTC) + } + + } +} diff --git a/core-types/src/test/scala/xyz/driver/core/CoreTest.scala b/core-types/src/test/scala/xyz/driver/core/CoreTest.scala new file mode 100644 index 0000000..f448d24 --- /dev/null +++ b/core-types/src/test/scala/xyz/driver/core/CoreTest.scala @@ -0,0 +1,88 @@ +package xyz.driver.core + +import java.io.ByteArrayOutputStream + +import org.mockito.Mockito._ +import org.scalatest.mockito.MockitoSugar +import org.scalatest.{FlatSpec, Matchers} + +class CoreTest extends FlatSpec with Matchers with MockitoSugar { + + "'make' function" should "allow initialization for objects" in { + + val createdAndInitializedValue = make(new ByteArrayOutputStream(128)) { baos => + baos.write(Array(1.toByte, 1.toByte, 0.toByte)) + } + + createdAndInitializedValue.toByteArray should be(Array(1.toByte, 1.toByte, 0.toByte)) + } + + "'using' function" should "call close after performing action on resource" in { + + val baos = mock[ByteArrayOutputStream] + + using(baos /* usually new ByteArrayOutputStream(128) */ ) { baos => + baos.write(Array(1.toByte, 1.toByte, 0.toByte)) + } + + verify(baos).close() + } + + "Id" should "have equality and ordering working correctly" in { + + (Id[String]("1234213") === Id[String]("1234213")) should be(true) + (Id[String]("1234213") === Id[String]("213414")) should be(false) + (Id[String]("213414") === Id[String]("1234213")) should be(false) + + val ids = Seq(Id[String]("4"), Id[String]("3"), Id[String]("2"), Id[String]("1")) + val sorted = Seq(Id[String]("1"), Id[String]("2"), Id[String]("3"), Id[String]("4")) + + ids.sorted should contain theSameElementsInOrderAs sorted + } + + it should "have type-safe conversions" in { + final case class X(id: Id[X]) + final case class Y(id: Id[Y]) + final case class Z(id: Id[Z]) + + implicit val xy = Id.Mapper[X, Y] + implicit val yz = Id.Mapper[Y, Z] + + // Test that implicit conversions work correctly + val x = X(Id("0")) + val y = Y(x.id) + val z = Z(y.id) + val y2 = Y(z.id) + val x2 = X(y2.id) + (x2 === x) should be(true) + (y2 === y) should be(true) + + // Test that type inferrence for explicit conversions work correctly + val yid = y.id + val xid = xy(yid) + val zid = yz(yid) + (xid: Id[X]) should be(zid: Id[Z]) + } + + "Name" should "have equality and ordering working correctly" in { + + (Name[String]("foo") === Name[String]("foo")) should be(true) + (Name[String]("foo") === Name[String]("bar")) should be(false) + (Name[String]("bar") === Name[String]("foo")) should be(false) + + val names = Seq(Name[String]("d"), Name[String]("cc"), Name[String]("a"), Name[String]("bbb")) + val sorted = Seq(Name[String]("a"), Name[String]("bbb"), Name[String]("cc"), Name[String]("d")) + names.sorted should contain theSameElementsInOrderAs sorted + } + + "Revision" should "have equality working correctly" in { + + val bla = Revision[String]("85569dab-a3dc-401b-9f95-d6fb4162674b") + val foo = Revision[String]("f54b3558-bdcd-4646-a14b-8beb11f6b7c4") + + (bla === bla) should be(true) + (bla === foo) should be(false) + (foo === bla) should be(false) + } + +} diff --git a/core-types/src/test/scala/xyz/driver/core/DateTest.scala b/core-types/src/test/scala/xyz/driver/core/DateTest.scala new file mode 100644 index 0000000..0432040 --- /dev/null +++ b/core-types/src/test/scala/xyz/driver/core/DateTest.scala @@ -0,0 +1,53 @@ +package xyz.driver.core + +import org.scalacheck.{Arbitrary, Gen} +import org.scalatest.prop.Checkers +import org.scalatest.{FlatSpec, Matchers} +import xyz.driver.core.date.Date + +class DateTest extends FlatSpec with Matchers with Checkers { + val dateGenerator = for { + year <- Gen.choose(0, 3000) + month <- Gen.choose(0, 11) + day <- Gen.choose(1, 31) + } yield Date(year, date.Month(month), day) + implicit val arbitraryDate = Arbitrary[Date](dateGenerator) + + "Date" should "correctly convert to and from String" in { + + import xyz.driver.core.generators.nextDate + import date._ + + for (date <- 1 to 100 map (_ => nextDate())) { + Some(date) should be(Date.fromString(date.toString)) + } + } + + it should "have ordering defined correctly" in { + Seq( + Date.fromString("2013-05-10"), + Date.fromString("2020-02-15"), + Date.fromString("2017-03-05"), + Date.fromString("2013-05-12")).sorted should + contain theSameElementsInOrderAs Seq( + Date.fromString("2013-05-10"), + Date.fromString("2013-05-12"), + Date.fromString("2017-03-05"), + Date.fromString("2020-02-15")) + + check { dates: List[Date] => + dates.sorted.sliding(2).filter(_.size == 2).forall { + case Seq(a, b) => + if (a.year == b.year) { + if (a.month == b.month) { + a.day <= b.day + } else { + a.month < b.month + } + } else { + a.year < b.year + } + } + } + } +} diff --git a/core-types/src/test/scala/xyz/driver/core/PhoneNumberTest.scala b/core-types/src/test/scala/xyz/driver/core/PhoneNumberTest.scala new file mode 100644 index 0000000..729302b --- /dev/null +++ b/core-types/src/test/scala/xyz/driver/core/PhoneNumberTest.scala @@ -0,0 +1,117 @@ +package xyz.driver.core + +import org.scalatest.{FlatSpec, Matchers} +import xyz.driver.core.domain.PhoneNumber + +class PhoneNumberTest extends FlatSpec with Matchers { + + "PhoneNumber.parse" should "recognize US numbers in international format, ignoring non-digits" in { + // format: off + val numbers = List( + "+18005252225", + "+1 800 525 2225", + "+1 (800) 525-2225", + "+1.800.525.2225") + // format: on + + val parsed = numbers.flatMap(PhoneNumber.parse) + + parsed should have size numbers.size + parsed should contain only PhoneNumber("1", "8005252225") + } + + it should "recognize US numbers without the plus sign" in { + PhoneNumber.parse("18005252225") shouldBe Some(PhoneNumber("1", "8005252225")) + } + + it should "recognize US numbers without country code" in { + // format: off + val numbers = List( + "8005252225", + "800 525 2225", + "(800) 525-2225", + "800.525.2225") + // format: on + + val parsed = numbers.flatMap(PhoneNumber.parse) + + parsed should have size numbers.size + parsed should contain only PhoneNumber("1", "8005252225") + } + + it should "recognize CN numbers in international format" in { + PhoneNumber.parse("+868005252225") shouldBe Some(PhoneNumber("86", "8005252225")) + 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 + val numbers = List( + "+1 800 525-222", + "+1 800 525-2", + "+86 800 525-222", + "+86 800 525-2") + // format: on + + numbers.flatMap(PhoneNumber.parse) shouldBe empty + } + + withClue("Argentinian numbers are 11 digits (when prefixed with 0) - 10 digit numbers shouldn't fit") { + // format: off + val numbers = List( + "+54 011 525-22256", + "+54 011 525-2225", + "+54 011 525-222") + // format: on + + numbers.flatMap(PhoneNumber.parse) should contain theSameElementsAs List(PhoneNumber("54", "1152522256")) + } + } + + it should "return None on numbers that are longer than the maximum number of digits for the country (i.e. DK - 8, CN - 11)" in { + val numbers = List("+45 27 45 25 22", "+45 135 525 223", "+86 134 525 22256", "+86 135 525 22256 7") + + numbers.flatMap(PhoneNumber.parse) should contain theSameElementsAs + 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") + } + +} diff --git a/core-types/src/test/scala/xyz/driver/core/TimeTest.scala b/core-types/src/test/scala/xyz/driver/core/TimeTest.scala new file mode 100644 index 0000000..1019f60 --- /dev/null +++ b/core-types/src/test/scala/xyz/driver/core/TimeTest.scala @@ -0,0 +1,143 @@ +package xyz.driver.core + +import java.util.TimeZone + +import org.scalacheck.Arbitrary._ +import org.scalacheck.Prop.BooleanOperators +import org.scalacheck.{Arbitrary, Gen} +import org.scalatest.prop.Checkers +import org.scalatest.{FlatSpec, Matchers} +import xyz.driver.core.date.Month +import xyz.driver.core.time.{Time, _} + +import scala.concurrent.duration._ +import scala.language.postfixOps + +class TimeTest extends FlatSpec with Matchers with Checkers { + + implicit val arbDuration = Arbitrary[Duration](Gen.chooseNum(0L, 9999999999L).map(_.milliseconds)) + implicit val arbTime = Arbitrary[Time](Gen.chooseNum(0L, 9999999999L).map(millis => Time(millis))) + + "Time" should "have correct methods to compare" in { + + Time(234L).isAfter(Time(123L)) should be(true) + Time(123L).isAfter(Time(123L)) should be(false) + Time(123L).isAfter(Time(234L)) should be(false) + + check((a: Time, b: Time) => (a.millis > b.millis) ==> a.isAfter(b)) + + Time(234L).isBefore(Time(123L)) should be(false) + Time(123L).isBefore(Time(123L)) should be(false) + Time(123L).isBefore(Time(234L)) should be(true) + + check { (a: Time, b: Time) => + (a.millis < b.millis) ==> a.isBefore(b) + } + } + + it should "not modify time" in { + + Time(234L).millis should be(234L) + + check { millis: Long => + Time(millis).millis == millis + } + } + + it should "support arithmetic with scala.concurrent.duration" in { + + Time(123L).advanceBy(0 minutes).millis should be(123L) + Time(123L).advanceBy(1 second).millis should be(123L + Second) + Time(123L).advanceBy(4 days).millis should be(123L + 4 * Days) + + check { (time: Time, duration: Duration) => + time.advanceBy(duration).millis == (time.millis + duration.toMillis) + } + } + + it should "have ordering defined correctly" in { + + Seq(Time(321L), Time(123L), Time(231L)).sorted should + contain theSameElementsInOrderAs Seq(Time(123L), Time(231L), Time(321L)) + + check { times: List[Time] => + times.sorted.sliding(2).filter(_.size == 2).forall { + case Seq(a, b) => + a.millis <= b.millis + } + } + } + + it should "reset to the start of the period, e.g. month" in { + + startOfMonth(Time(1468937089834L)) should be(Time(1467381889834L)) + startOfMonth(Time(1467381889834L)) should be(Time(1467381889834L)) // idempotent + } + + it should "have correct textual representations" in { + import java.util.Locale + import java.util.Locale._ + Locale.setDefault(US) + + textualDate(TimeZone.getTimeZone("EDT"))(Time(1468937089834L)) should be("July 19, 2016") + textualTime(TimeZone.getTimeZone("PDT"))(Time(1468937089834L)) should be("Jul 19, 2016 02:04:49 PM") + } + + "TimeRange" should "have duration defined as a difference of start and end times" in { + + TimeRange(Time(321L), Time(432L)).duration should be(111.milliseconds) + TimeRange(Time(432L), Time(321L)).duration should be((-111).milliseconds) + TimeRange(Time(333L), Time(333L)).duration should be(0.milliseconds) + } + + "Time" should "use TimeZone correctly when converting to Date" in { + + val EST = java.util.TimeZone.getTimeZone("EST") + val PST = java.util.TimeZone.getTimeZone("PST") + + val timestamp = { + import java.util.Calendar + val cal = Calendar.getInstance(EST) + cal.set(Calendar.HOUR_OF_DAY, 1) + Time(cal.getTime().getTime()) + } + + textualDate(EST)(timestamp) should not be textualDate(PST)(timestamp) + timestamp.toDate(EST) should not be timestamp.toDate(PST) + } + + "TimeOfDay" should "be created from valid strings and convert to java.sql.Time" in { + val s = "07:30:45" + val defaultTimeZone = TimeZone.getDefault() + val todFactory = TimeOfDay.parseTimeString(defaultTimeZone)(_) + val tod = todFactory(s) + tod.timeString shouldBe s + tod.timeZoneString shouldBe defaultTimeZone.getID + val sqlTime = tod.toTime + sqlTime.toLocalTime shouldBe tod.localTime + a[java.time.format.DateTimeParseException] should be thrownBy { + val illegal = "7:15" + todFactory(illegal) + } + } + + "TimeOfDay" should "have correct temporal relationships" in { + val s = "07:30:45" + val t = "09:30:45" + val pst = TimeZone.getTimeZone("America/Los_Angeles") + val est = TimeZone.getTimeZone("America/New_York") + val pstTodFactory = TimeOfDay.parseTimeString(pst)(_) + val estTodFactory = TimeOfDay.parseTimeString(est)(_) + val day = 1 + val month = Month.JANUARY + val year = 2018 + val sTodPst = pstTodFactory(s) + val sTodPst2 = pstTodFactory(s) + val tTodPst = pstTodFactory(t) + val tTodEst = estTodFactory(t) + sTodPst.isBefore(tTodPst, day, month, year) shouldBe true + tTodPst.isAfter(sTodPst, day, month, year) shouldBe true + tTodEst.isBefore(sTodPst, day, month, year) shouldBe true + sTodPst.sameTimeAs(sTodPst2, day, month, year) shouldBe true + } +} diff --git a/core-types/src/test/scala/xyz/driver/core/tagging/TaggingTest.scala b/core-types/src/test/scala/xyz/driver/core/tagging/TaggingTest.scala new file mode 100644 index 0000000..14dfaf9 --- /dev/null +++ b/core-types/src/test/scala/xyz/driver/core/tagging/TaggingTest.scala @@ -0,0 +1,63 @@ +package xyz.driver.core.tagging + +import org.scalatest.{Matchers, WordSpec} +import xyz.driver.core.{@@, Name} + +/** + * @author sergey + * @since 9/11/18 + */ +class TaggingTest extends WordSpec with Matchers { + + "@@ Trimmed" should { + "produce values transparently from Strings and Names (by default)" in { + val s: String @@ Trimmed = " trimmed " + val n: Name[Int] @@ Trimmed = Name(" trimmed ") + + s shouldBe "trimmed" + n shouldBe Name[Int]("trimmed") + } + + "produce values transparently from values that have an implicit conversion defined" in { + import scala.language.implicitConversions + implicit def stringSeq2Trimmed(stringSeq: Seq[String]): Seq[String] @@ Trimmed = + stringSeq.map(_.trim()).tagged[Trimmed] + + val strings: Seq[String] @@ Trimmed = Seq(" trimmed1 ", " trimmed2 ") + strings shouldBe Seq("trimmed1", "trimmed2") + } + + "produce values transparently from Options of values that have Trimmed implicits" in { + val maybeStringDirect: Option[String @@ Trimmed] = Some(" trimmed ") + val maybeStringFromMap: Option[String @@ Trimmed] = Map("s" -> " trimmed ").get("s") + + val maybeNameDirect: Option[Name[Int] @@ Trimmed] = Some(Name(" trimmed ")) + val maybeNameFromMap: Option[Name[Int] @@ Trimmed] = Map("s" -> Name[Int](" trimmed ")).get("s") + + maybeStringDirect shouldBe Some("trimmed") + maybeStringFromMap shouldBe Some("trimmed") + maybeNameDirect shouldBe Some(Name[Int]("trimmed")) + maybeNameFromMap shouldBe Some(Name[Int]("trimmed")) + } + + "produce values transparently from collections of values that have Trimmed implicits" in { + val strings = Seq("s" -> " trimmed1 ", "s" -> " trimmed2 ") + val names = strings.map { + case (k, v) => k -> Name[Int](v) + } + + val trimmedStrings: Seq[String @@ Trimmed] = strings.groupBy(_._1)("s").map(_._2) + val trimmedNames: Seq[Name[Int] @@ Trimmed] = names.groupBy(_._1)("s").map(_._2) + + trimmedStrings shouldBe Seq("trimmed1", "trimmed2") + trimmedNames shouldBe Seq("trimmed1", "trimmed2").map(Name[Int]) + } + + "have Ordering" in { + val names: Seq[Name[Int] @@ Trimmed] = Seq(" 2 ", " 1 ", "3").map(Name[Int]) + + names.sorted should contain inOrderOnly (Name("1"), Name("2"), Name("3")) + } + } + +} diff --git a/src/main/scala/xyz/driver/core/core.scala b/src/main/scala/xyz/driver/core/core.scala deleted file mode 100644 index 2ab4e88..0000000 --- a/src/main/scala/xyz/driver/core/core.scala +++ /dev/null @@ -1,124 +0,0 @@ -package xyz.driver - -import eu.timepit.refined.api.{Refined, Validate} -import eu.timepit.refined.collection.NonEmpty -import scalaz.{Equal, Monad, OptionT} -import xyz.driver.core.rest.errors.ExternalServiceException -import xyz.driver.core.tagging.Tagged - -import scala.concurrent.{ExecutionContext, Future} - -// TODO: this package seems too complex, look at all the features we need! -import scala.language.{higherKinds, implicitConversions, reflectiveCalls} - -package object core { - - def make[T](v: => T)(f: T => Unit): T = { - val value = v - f(value) - value - } - - def using[R <: { def close() }, P](r: => R)(f: R => P): P = { - val resource = r - try { - f(resource) - } finally { - resource.close() - } - } - - type @@[+V, +Tag] = V with Tagged[V, Tag] - - implicit class OptionTExtensions[H[_]: Monad, T](optionTValue: OptionT[H, T]) { - - def returnUnit: H[Unit] = optionTValue.fold[Unit](_ => (), ()) - - def continueIgnoringNone: OptionT[H, Unit] = - optionTValue.map(_ => ()).orElse(OptionT.some[H, Unit](())) - - def subflatMap[B](f: T => Option[B]): OptionT[H, B] = - OptionT.optionT[H](implicitly[Monad[H]].map(optionTValue.run)(_.flatMap(f))) - } - - implicit class MonadicExtensions[H[_]: Monad, T](monadicValue: H[T]) { - private implicit val monadT = implicitly[Monad[H]] - - def returnUnit: H[Unit] = monadT(monadicValue)(_ => ()) - - def toOptionT: OptionT[H, T] = - OptionT.optionT[H](monadT(monadicValue)(value => Option(value))) - - def toUnitOptionT: OptionT[H, Unit] = - OptionT.optionT[H](monadT(monadicValue)(_ => Option(()))) - } - - implicit class FutureExtensions[T](future: Future[T]) { - def passThroughExternalServiceException(implicit executionContext: ExecutionContext): Future[T] = - future.transform(identity, { - case ExternalServiceException(_, _, Some(e)) => e - case t: Throwable => t - }) - } -} - -package core { - - final case class Id[+Tag](value: String) extends AnyVal { - @inline def length: Int = value.length - override def toString: String = value - } - - @SuppressWarnings(Array("org.wartremover.warts.ImplicitConversion")) - object Id { - implicit def idEqual[T]: Equal[Id[T]] = Equal.equal[Id[T]](_ == _) - implicit def idOrdering[T]: Ordering[Id[T]] = Ordering.by[Id[T], String](_.value) - - sealed class Mapper[E, R] { - def apply[T >: E](id: Id[R]): Id[T] = Id[E](id.value) - def apply[T >: R](id: Id[E])(implicit dummy: DummyImplicit): Id[T] = Id[R](id.value) - } - object Mapper { - def apply[E, R] = new Mapper[E, R] - } - implicit def convertRE[R, E](id: Id[R])(implicit mapper: Mapper[E, R]): Id[E] = mapper[E](id) - implicit def convertER[E, R](id: Id[E])(implicit mapper: Mapper[E, R]): Id[R] = mapper[R](id) - } - - final case class Name[+Tag](value: String) extends AnyVal { - @inline def length: Int = value.length - override def toString: String = value - } - - object Name { - implicit def nameEqual[T]: Equal[Name[T]] = Equal.equal[Name[T]](_ == _) - implicit def nameOrdering[T]: Ordering[Name[T]] = Ordering.by(_.value) - - implicit def nameValidator[T, P](implicit stringValidate: Validate[String, P]): Validate[Name[T], P] = { - Validate.instance[Name[T], P, stringValidate.R]( - name => stringValidate.validate(name.value), - name => stringValidate.showExpr(name.value)) - } - } - - final case class NonEmptyName[+Tag](value: String Refined NonEmpty) { - @inline def length: Int = value.value.length - override def toString: String = value.value - } - - object NonEmptyName { - implicit def nonEmptyNameEqual[T]: Equal[NonEmptyName[T]] = - Equal.equal[NonEmptyName[T]](_.value.value == _.value.value) - - implicit def nonEmptyNameOrdering[T]: Ordering[NonEmptyName[T]] = Ordering.by(_.value.value) - } - - final case class Revision[T](id: String) - - object Revision { - implicit def revisionEqual[T]: Equal[Revision[T]] = Equal.equal[Revision[T]](_.id == _.id) - } - - final case class Base64(value: String) - -} diff --git a/src/main/scala/xyz/driver/core/date.scala b/src/main/scala/xyz/driver/core/date.scala deleted file mode 100644 index 5454093..0000000 --- a/src/main/scala/xyz/driver/core/date.scala +++ /dev/null @@ -1,109 +0,0 @@ -package xyz.driver.core - -import java.util.Calendar - -import enumeratum._ -import scalaz.std.anyVal._ -import scalaz.syntax.equal._ - -import scala.collection.immutable.IndexedSeq -import scala.util.Try - -/** - * Driver Date type and related validators/extractors. - * Day, Month, and Year extractors are from ISO 8601 strings => driver...Date integers. - * TODO: Decouple extractors from ISO 8601, as we might want to parse other formats. - */ -object date { - - sealed trait DayOfWeek extends EnumEntry - object DayOfWeek extends Enum[DayOfWeek] { - case object Monday extends DayOfWeek - case object Tuesday extends DayOfWeek - case object Wednesday extends DayOfWeek - case object Thursday extends DayOfWeek - case object Friday extends DayOfWeek - case object Saturday extends DayOfWeek - case object Sunday extends DayOfWeek - - val values: IndexedSeq[DayOfWeek] = findValues - - val All: Set[DayOfWeek] = values.toSet - - def fromString(day: String): Option[DayOfWeek] = withNameInsensitiveOption(day) - } - - type Day = Int @@ Day.type - - object Day { - def apply(value: Int): Day = { - require(1 to 31 contains value, "Day must be in range 1 <= value <= 31") - value.asInstanceOf[Day] - } - - def unapply(dayString: String): Option[Int] = { - require(dayString.length === 2, s"ISO 8601 day string, DD, must have length 2: $dayString") - Try(dayString.toInt).toOption.map(apply) - } - } - - type Month = Int @@ Month.type - - object Month { - def apply(value: Int): Month = { - require(0 to 11 contains value, "Month is zero-indexed: 0 <= value <= 11") - value.asInstanceOf[Month] - } - val JANUARY = Month(Calendar.JANUARY) - val FEBRUARY = Month(Calendar.FEBRUARY) - val MARCH = Month(Calendar.MARCH) - val APRIL = Month(Calendar.APRIL) - val MAY = Month(Calendar.MAY) - val JUNE = Month(Calendar.JUNE) - val JULY = Month(Calendar.JULY) - val AUGUST = Month(Calendar.AUGUST) - val SEPTEMBER = Month(Calendar.SEPTEMBER) - val OCTOBER = Month(Calendar.OCTOBER) - val NOVEMBER = Month(Calendar.NOVEMBER) - val DECEMBER = Month(Calendar.DECEMBER) - - def unapply(monthString: String): Option[Month] = { - require(monthString.length === 2, s"ISO 8601 month string, MM, must have length 2: $monthString") - Try(monthString.toInt).toOption.map(isoM => apply(isoM - 1)) - } - } - - type Year = Int @@ Year.type - - object Year { - def apply(value: Int): Year = value.asInstanceOf[Year] - - def unapply(yearString: String): Option[Int] = { - require(yearString.length === 4, s"ISO 8601 year string, YYYY, must have length 4: $yearString") - Try(yearString.toInt).toOption.map(apply) - } - } - - final case class Date(year: Int, month: Month, day: Int) { - override def toString = f"$year%04d-${month + 1}%02d-$day%02d" - } - - object Date { - implicit def dateOrdering: Ordering[Date] = Ordering.fromLessThan { (date1, date2) => - if (date1.year != date2.year) { - date1.year < date2.year - } else if (date1.month != date2.month) { - date1.month < date2.month - } else { - date1.day < date2.day - } - } - - def fromString(dateString: String): Option[Date] = { - dateString.split('-') match { - case Array(Year(year), Month(month), Day(day)) => Some(Date(year, month, day)) - case _ => None - } - } - } -} diff --git a/src/main/scala/xyz/driver/core/domain.scala b/src/main/scala/xyz/driver/core/domain.scala deleted file mode 100644 index f3b8337..0000000 --- a/src/main/scala/xyz/driver/core/domain.scala +++ /dev/null @@ -1,73 +0,0 @@ -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 - } - - object Email { - implicit val emailEqual: Equal[Email] = Equal.equal { - case (left, right) => left.toString.toLowerCase === right.toString.toLowerCase - } - - def parse(emailString: String): Option[Email] = { - Some(emailString.split("@")) collect { - case Array(username, domain) => Email(username, domain) - } - } - } - - 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[PhoneNumber] val phoneUtil = PhoneNumberUtil.getInstance() - - def parse(phoneNumber: String): Option[PhoneNumber] = { - 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/rest/package.scala b/src/main/scala/xyz/driver/core/rest/package.scala index 3be8f02..34a4a9d 100644 --- a/src/main/scala/xyz/driver/core/rest/package.scala +++ b/src/main/scala/xyz/driver/core/rest/package.scala @@ -15,10 +15,11 @@ import scalaz.Scalaz.{intInstance, stringInstance} import scalaz.syntax.equal._ import scalaz.{Functor, OptionT} import xyz.driver.core.rest.auth.AuthProvider +import xyz.driver.core.rest.errors.ExternalServiceException import xyz.driver.core.rest.headers.Traceparent import xyz.driver.tracing.TracingDirectives -import scala.concurrent.Future +import scala.concurrent.{ExecutionContext, Future} import scala.util.Try trait Service @@ -72,6 +73,15 @@ object ListResponse { } object `package` { + + implicit class FutureExtensions[T](future: Future[T]) { + def passThroughExternalServiceException(implicit executionContext: ExecutionContext): Future[T] = + future.transform(identity, { + case ExternalServiceException(_, _, Some(e)) => e + case t: Throwable => t + }) + } + implicit class OptionTRestAdditions[T](optionT: OptionT[Future, T]) { def responseOrNotFound(successCode: StatusCodes.Success = StatusCodes.OK)( implicit F: Functor[Future], diff --git a/src/main/scala/xyz/driver/core/tagging/tagging.scala b/src/main/scala/xyz/driver/core/tagging/tagging.scala deleted file mode 100644 index 5b6599e..0000000 --- a/src/main/scala/xyz/driver/core/tagging/tagging.scala +++ /dev/null @@ -1,62 +0,0 @@ -package xyz.driver.core - -import scala.collection.generic.CanBuildFrom -import scala.language.{higherKinds, implicitConversions} - -/** - * @author sergey - * @since 9/11/18 - */ -package object tagging { - - implicit class Taggable[V <: Any](val v: V) extends AnyVal { - def tagged[Tag]: V @@ Tag = v.asInstanceOf[V @@ Tag] - } - -} - -package tagging { - - sealed trait Tagged[+V, +Tag] - - object Tagged { - implicit class TaggedOps[V, Tag](val v: V @@ Tag) extends AnyVal { - def tagless: V = v - } - - implicit def orderingMagnet[V, Tag](implicit ord: Ordering[V]): Ordering[V @@ Tag] = - ord.asInstanceOf[Ordering[V @@ Tag]] - - } - - sealed trait Trimmed - - object Trimmed { - - implicit def apply[V](trimmable: V)(implicit ev: CanBeTrimmed[V]): V @@ Trimmed = { - ev.trim(trimmable).tagged[Trimmed] - } - - sealed trait CanBeTrimmed[T] { - def trim(trimmable: T): T - } - - implicit object StringCanBeTrimmed extends CanBeTrimmed[String] { - def trim(str: String): String = str.trim() - } - - implicit def nameCanBeTrimmed[T]: CanBeTrimmed[Name[T]] = new CanBeTrimmed[Name[T]] { - def trim(name: Name[T]): Name[T] = Name[T](name.value.trim()) - } - - implicit def option2Trimmed[V: CanBeTrimmed](option: Option[V]): Option[V @@ Trimmed] = - option.map(Trimmed(_)) - - implicit def coll2Trimmed[T, C[_] <: Traversable[_]](coll: C[T])( - implicit ev: C[T] <:< Traversable[T], - tr: CanBeTrimmed[T], - bf: CanBuildFrom[Nothing, T @@ Trimmed, C[T @@ Trimmed]]): C[T @@ Trimmed] = - ev(coll).map(Trimmed(_)(tr)).to[C] - } - -} diff --git a/src/main/scala/xyz/driver/core/time.scala b/src/main/scala/xyz/driver/core/time.scala deleted file mode 100644 index 1622068..0000000 --- a/src/main/scala/xyz/driver/core/time.scala +++ /dev/null @@ -1,209 +0,0 @@ -package xyz.driver.core - -import java.text.SimpleDateFormat -import java.time.{Clock, Instant, ZoneId, ZoneOffset} -import java.util._ -import java.util.concurrent.TimeUnit - -import xyz.driver.core.date.Month - -import scala.concurrent.duration._ -import scala.language.implicitConversions -import scala.util.Try - -object time { - - // The most useful time units - val Second = 1000L - val Seconds = Second - val Minute = 60 * Seconds - val Minutes = Minute - val Hour = 60 * Minutes - val Hours = Hour - val Day = 24 * Hours - val Days = Day - val Week = 7 * Days - val Weeks = Week - - final case class Time(millis: Long) extends AnyVal { - - def isBefore(anotherTime: Time): Boolean = implicitly[Ordering[Time]].lt(this, anotherTime) - - def isAfter(anotherTime: Time): Boolean = implicitly[Ordering[Time]].gt(this, anotherTime) - - def advanceBy(duration: Duration): Time = Time(millis + duration.toMillis) - - def durationTo(anotherTime: Time): Duration = Duration.apply(anotherTime.millis - millis, TimeUnit.MILLISECONDS) - - def durationFrom(anotherTime: Time): Duration = Duration.apply(millis - anotherTime.millis, TimeUnit.MILLISECONDS) - - def toDate(timezone: TimeZone): date.Date = { - val cal = Calendar.getInstance(timezone) - cal.setTimeInMillis(millis) - date.Date(cal.get(Calendar.YEAR), date.Month(cal.get(Calendar.MONTH)), cal.get(Calendar.DAY_OF_MONTH)) - } - - def toInstant: Instant = Instant.ofEpochMilli(millis) - } - - object Time { - implicit def timeOrdering: Ordering[Time] = Ordering.by(_.millis) - - implicit def apply(instant: Instant): Time = Time(instant.toEpochMilli) - } - - /** - * Encapsulates a time and timezone without a specific date. - */ - final case class TimeOfDay(localTime: java.time.LocalTime, timeZone: TimeZone) { - - /** - * Is this time before another time on a specific day. Day light savings safe. These are zero-indexed - * for month/day. - */ - def isBefore(other: TimeOfDay, day: Int, month: Month, year: Int): Boolean = { - toCalendar(day, month, year).before(other.toCalendar(day, month, year)) - } - - /** - * Is this time after another time on a specific day. Day light savings safe. - */ - def isAfter(other: TimeOfDay, day: Int, month: Month, year: Int): Boolean = { - toCalendar(day, month, year).after(other.toCalendar(day, month, year)) - } - - def sameTimeAs(other: TimeOfDay, day: Int, month: Month, year: Int): Boolean = { - toCalendar(day, month, year).equals(other.toCalendar(day, month, year)) - } - - /** - * Enforces the same formatting as expected by [[java.sql.Time]] - * @return string formatted for `java.sql.Time` - */ - def timeString: String = { - localTime.format(TimeOfDay.getFormatter) - } - - /** - * @return a string parsable by [[java.util.TimeZone]] - */ - def timeZoneString: String = { - timeZone.getID - } - - /** - * @return this [[TimeOfDay]] as [[java.sql.Time]] object, [[java.sql.Time.valueOf]] will - * throw when the string is not valid, but this is protected by [[timeString]] method. - */ - def toTime: java.sql.Time = { - java.sql.Time.valueOf(timeString) - } - - private def toCalendar(day: Int, month: Int, year: Int): Calendar = { - val cal = Calendar.getInstance(timeZone) - cal.set(year, month, day, localTime.getHour, localTime.getMinute, localTime.getSecond) - cal.clear(Calendar.MILLISECOND) - cal - } - } - - object TimeOfDay { - def now(): TimeOfDay = { - TimeOfDay(java.time.LocalTime.now(), TimeZone.getDefault) - } - - /** - * Throws when [s] is not parsable by [[java.time.LocalTime.parse]], uses default [[java.util.TimeZone]] - */ - def parseTimeString(tz: TimeZone = TimeZone.getDefault)(s: String): TimeOfDay = { - TimeOfDay(java.time.LocalTime.parse(s), tz) - } - - def fromString(tz: TimeZone)(s: String): Option[TimeOfDay] = { - val op = Try(java.time.LocalTime.parse(s)).toOption - op.map(lt => TimeOfDay(lt, tz)) - } - - def fromStrings(zoneId: String)(s: String): Option[TimeOfDay] = { - val op = Try(TimeZone.getTimeZone(zoneId)).toOption - op.map(tz => TimeOfDay.parseTimeString(tz)(s)) - } - - /** - * Formatter that enforces `HH:mm:ss` which is expected by [[java.sql.Time]] - */ - def getFormatter: java.time.format.DateTimeFormatter = { - java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss") - } - } - - final case class TimeRange(start: Time, end: Time) { - def duration: Duration = FiniteDuration(end.millis - start.millis, MILLISECONDS) - } - - def startOfMonth(time: Time) = { - Time(make(new GregorianCalendar()) { cal => - cal.setTime(new Date(time.millis)) - cal.set(Calendar.DAY_OF_MONTH, cal.getActualMinimum(Calendar.DAY_OF_MONTH)) - }.getTime.getTime) - } - - def textualDate(timezone: TimeZone)(time: Time): String = - make(new SimpleDateFormat("MMMM d, yyyy"))(_.setTimeZone(timezone)).format(new Date(time.millis)) - - def textualTime(timezone: TimeZone)(time: Time): String = - make(new SimpleDateFormat("MMM dd, yyyy hh:mm:ss a"))(_.setTimeZone(timezone)).format(new Date(time.millis)) - - class ChangeableClock(@volatile var instant: Instant, val zone: ZoneId = ZoneOffset.UTC) extends Clock { - - def tick(duration: FiniteDuration): Unit = - instant = instant.plusNanos(duration.toNanos) - - val getZone: ZoneId = zone - - def withZone(zone: ZoneId): Clock = new ChangeableClock(instant, zone = zone) - - override def toString: String = "ChangeableClock(" + instant + "," + zone + ")" - } - - object provider { - - /** - * Time providers are supplying code with current times - * and are extremely useful for testing to check how system is going - * to behave at specific moments in time. - * - * All the calls to receive current time must be made using time - * provider injected to the caller. - */ - @deprecated( - "Use java.time.Clock instead. Note that xyz.driver.core.Time and xyz.driver.core.date.Date will also be deprecated soon!", - "0.13.0") - trait TimeProvider { - def currentTime(): Time - def toClock: Clock - } - - final implicit class ClockTimeProvider(clock: Clock) extends TimeProvider { - def currentTime(): Time = Time(clock.instant().toEpochMilli) - - val toClock: Clock = clock - } - - final class SystemTimeProvider extends TimeProvider { - def currentTime() = Time(System.currentTimeMillis()) - - lazy val toClock: Clock = Clock.systemUTC() - } - - final val SystemTimeProvider = new SystemTimeProvider - - final class SpecificTimeProvider(time: Time) extends TimeProvider { - - def currentTime(): Time = time - - lazy val toClock: Clock = Clock.fixed(time.toInstant, ZoneOffset.UTC) - } - - } -} diff --git a/src/test/scala/xyz/driver/core/CoreTest.scala b/src/test/scala/xyz/driver/core/CoreTest.scala deleted file mode 100644 index f448d24..0000000 --- a/src/test/scala/xyz/driver/core/CoreTest.scala +++ /dev/null @@ -1,88 +0,0 @@ -package xyz.driver.core - -import java.io.ByteArrayOutputStream - -import org.mockito.Mockito._ -import org.scalatest.mockito.MockitoSugar -import org.scalatest.{FlatSpec, Matchers} - -class CoreTest extends FlatSpec with Matchers with MockitoSugar { - - "'make' function" should "allow initialization for objects" in { - - val createdAndInitializedValue = make(new ByteArrayOutputStream(128)) { baos => - baos.write(Array(1.toByte, 1.toByte, 0.toByte)) - } - - createdAndInitializedValue.toByteArray should be(Array(1.toByte, 1.toByte, 0.toByte)) - } - - "'using' function" should "call close after performing action on resource" in { - - val baos = mock[ByteArrayOutputStream] - - using(baos /* usually new ByteArrayOutputStream(128) */ ) { baos => - baos.write(Array(1.toByte, 1.toByte, 0.toByte)) - } - - verify(baos).close() - } - - "Id" should "have equality and ordering working correctly" in { - - (Id[String]("1234213") === Id[String]("1234213")) should be(true) - (Id[String]("1234213") === Id[String]("213414")) should be(false) - (Id[String]("213414") === Id[String]("1234213")) should be(false) - - val ids = Seq(Id[String]("4"), Id[String]("3"), Id[String]("2"), Id[String]("1")) - val sorted = Seq(Id[String]("1"), Id[String]("2"), Id[String]("3"), Id[String]("4")) - - ids.sorted should contain theSameElementsInOrderAs sorted - } - - it should "have type-safe conversions" in { - final case class X(id: Id[X]) - final case class Y(id: Id[Y]) - final case class Z(id: Id[Z]) - - implicit val xy = Id.Mapper[X, Y] - implicit val yz = Id.Mapper[Y, Z] - - // Test that implicit conversions work correctly - val x = X(Id("0")) - val y = Y(x.id) - val z = Z(y.id) - val y2 = Y(z.id) - val x2 = X(y2.id) - (x2 === x) should be(true) - (y2 === y) should be(true) - - // Test that type inferrence for explicit conversions work correctly - val yid = y.id - val xid = xy(yid) - val zid = yz(yid) - (xid: Id[X]) should be(zid: Id[Z]) - } - - "Name" should "have equality and ordering working correctly" in { - - (Name[String]("foo") === Name[String]("foo")) should be(true) - (Name[String]("foo") === Name[String]("bar")) should be(false) - (Name[String]("bar") === Name[String]("foo")) should be(false) - - val names = Seq(Name[String]("d"), Name[String]("cc"), Name[String]("a"), Name[String]("bbb")) - val sorted = Seq(Name[String]("a"), Name[String]("bbb"), Name[String]("cc"), Name[String]("d")) - names.sorted should contain theSameElementsInOrderAs sorted - } - - "Revision" should "have equality working correctly" in { - - val bla = Revision[String]("85569dab-a3dc-401b-9f95-d6fb4162674b") - val foo = Revision[String]("f54b3558-bdcd-4646-a14b-8beb11f6b7c4") - - (bla === bla) should be(true) - (bla === foo) should be(false) - (foo === bla) should be(false) - } - -} diff --git a/src/test/scala/xyz/driver/core/DateTest.scala b/src/test/scala/xyz/driver/core/DateTest.scala deleted file mode 100644 index 0432040..0000000 --- a/src/test/scala/xyz/driver/core/DateTest.scala +++ /dev/null @@ -1,53 +0,0 @@ -package xyz.driver.core - -import org.scalacheck.{Arbitrary, Gen} -import org.scalatest.prop.Checkers -import org.scalatest.{FlatSpec, Matchers} -import xyz.driver.core.date.Date - -class DateTest extends FlatSpec with Matchers with Checkers { - val dateGenerator = for { - year <- Gen.choose(0, 3000) - month <- Gen.choose(0, 11) - day <- Gen.choose(1, 31) - } yield Date(year, date.Month(month), day) - implicit val arbitraryDate = Arbitrary[Date](dateGenerator) - - "Date" should "correctly convert to and from String" in { - - import xyz.driver.core.generators.nextDate - import date._ - - for (date <- 1 to 100 map (_ => nextDate())) { - Some(date) should be(Date.fromString(date.toString)) - } - } - - it should "have ordering defined correctly" in { - Seq( - Date.fromString("2013-05-10"), - Date.fromString("2020-02-15"), - Date.fromString("2017-03-05"), - Date.fromString("2013-05-12")).sorted should - contain theSameElementsInOrderAs Seq( - Date.fromString("2013-05-10"), - Date.fromString("2013-05-12"), - Date.fromString("2017-03-05"), - Date.fromString("2020-02-15")) - - check { dates: List[Date] => - dates.sorted.sliding(2).filter(_.size == 2).forall { - case Seq(a, b) => - if (a.year == b.year) { - if (a.month == b.month) { - a.day <= b.day - } else { - a.month < b.month - } - } else { - a.year < b.year - } - } - } - } -} diff --git a/src/test/scala/xyz/driver/core/PhoneNumberTest.scala b/src/test/scala/xyz/driver/core/PhoneNumberTest.scala deleted file mode 100644 index 729302b..0000000 --- a/src/test/scala/xyz/driver/core/PhoneNumberTest.scala +++ /dev/null @@ -1,117 +0,0 @@ -package xyz.driver.core - -import org.scalatest.{FlatSpec, Matchers} -import xyz.driver.core.domain.PhoneNumber - -class PhoneNumberTest extends FlatSpec with Matchers { - - "PhoneNumber.parse" should "recognize US numbers in international format, ignoring non-digits" in { - // format: off - val numbers = List( - "+18005252225", - "+1 800 525 2225", - "+1 (800) 525-2225", - "+1.800.525.2225") - // format: on - - val parsed = numbers.flatMap(PhoneNumber.parse) - - parsed should have size numbers.size - parsed should contain only PhoneNumber("1", "8005252225") - } - - it should "recognize US numbers without the plus sign" in { - PhoneNumber.parse("18005252225") shouldBe Some(PhoneNumber("1", "8005252225")) - } - - it should "recognize US numbers without country code" in { - // format: off - val numbers = List( - "8005252225", - "800 525 2225", - "(800) 525-2225", - "800.525.2225") - // format: on - - val parsed = numbers.flatMap(PhoneNumber.parse) - - parsed should have size numbers.size - parsed should contain only PhoneNumber("1", "8005252225") - } - - it should "recognize CN numbers in international format" in { - PhoneNumber.parse("+868005252225") shouldBe Some(PhoneNumber("86", "8005252225")) - 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 - val numbers = List( - "+1 800 525-222", - "+1 800 525-2", - "+86 800 525-222", - "+86 800 525-2") - // format: on - - numbers.flatMap(PhoneNumber.parse) shouldBe empty - } - - withClue("Argentinian numbers are 11 digits (when prefixed with 0) - 10 digit numbers shouldn't fit") { - // format: off - val numbers = List( - "+54 011 525-22256", - "+54 011 525-2225", - "+54 011 525-222") - // format: on - - numbers.flatMap(PhoneNumber.parse) should contain theSameElementsAs List(PhoneNumber("54", "1152522256")) - } - } - - it should "return None on numbers that are longer than the maximum number of digits for the country (i.e. DK - 8, CN - 11)" in { - val numbers = List("+45 27 45 25 22", "+45 135 525 223", "+86 134 525 22256", "+86 135 525 22256 7") - - numbers.flatMap(PhoneNumber.parse) should contain theSameElementsAs - 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") - } - -} diff --git a/src/test/scala/xyz/driver/core/TimeTest.scala b/src/test/scala/xyz/driver/core/TimeTest.scala deleted file mode 100644 index 1019f60..0000000 --- a/src/test/scala/xyz/driver/core/TimeTest.scala +++ /dev/null @@ -1,143 +0,0 @@ -package xyz.driver.core - -import java.util.TimeZone - -import org.scalacheck.Arbitrary._ -import org.scalacheck.Prop.BooleanOperators -import org.scalacheck.{Arbitrary, Gen} -import org.scalatest.prop.Checkers -import org.scalatest.{FlatSpec, Matchers} -import xyz.driver.core.date.Month -import xyz.driver.core.time.{Time, _} - -import scala.concurrent.duration._ -import scala.language.postfixOps - -class TimeTest extends FlatSpec with Matchers with Checkers { - - implicit val arbDuration = Arbitrary[Duration](Gen.chooseNum(0L, 9999999999L).map(_.milliseconds)) - implicit val arbTime = Arbitrary[Time](Gen.chooseNum(0L, 9999999999L).map(millis => Time(millis))) - - "Time" should "have correct methods to compare" in { - - Time(234L).isAfter(Time(123L)) should be(true) - Time(123L).isAfter(Time(123L)) should be(false) - Time(123L).isAfter(Time(234L)) should be(false) - - check((a: Time, b: Time) => (a.millis > b.millis) ==> a.isAfter(b)) - - Time(234L).isBefore(Time(123L)) should be(false) - Time(123L).isBefore(Time(123L)) should be(false) - Time(123L).isBefore(Time(234L)) should be(true) - - check { (a: Time, b: Time) => - (a.millis < b.millis) ==> a.isBefore(b) - } - } - - it should "not modify time" in { - - Time(234L).millis should be(234L) - - check { millis: Long => - Time(millis).millis == millis - } - } - - it should "support arithmetic with scala.concurrent.duration" in { - - Time(123L).advanceBy(0 minutes).millis should be(123L) - Time(123L).advanceBy(1 second).millis should be(123L + Second) - Time(123L).advanceBy(4 days).millis should be(123L + 4 * Days) - - check { (time: Time, duration: Duration) => - time.advanceBy(duration).millis == (time.millis + duration.toMillis) - } - } - - it should "have ordering defined correctly" in { - - Seq(Time(321L), Time(123L), Time(231L)).sorted should - contain theSameElementsInOrderAs Seq(Time(123L), Time(231L), Time(321L)) - - check { times: List[Time] => - times.sorted.sliding(2).filter(_.size == 2).forall { - case Seq(a, b) => - a.millis <= b.millis - } - } - } - - it should "reset to the start of the period, e.g. month" in { - - startOfMonth(Time(1468937089834L)) should be(Time(1467381889834L)) - startOfMonth(Time(1467381889834L)) should be(Time(1467381889834L)) // idempotent - } - - it should "have correct textual representations" in { - import java.util.Locale - import java.util.Locale._ - Locale.setDefault(US) - - textualDate(TimeZone.getTimeZone("EDT"))(Time(1468937089834L)) should be("July 19, 2016") - textualTime(TimeZone.getTimeZone("PDT"))(Time(1468937089834L)) should be("Jul 19, 2016 02:04:49 PM") - } - - "TimeRange" should "have duration defined as a difference of start and end times" in { - - TimeRange(Time(321L), Time(432L)).duration should be(111.milliseconds) - TimeRange(Time(432L), Time(321L)).duration should be((-111).milliseconds) - TimeRange(Time(333L), Time(333L)).duration should be(0.milliseconds) - } - - "Time" should "use TimeZone correctly when converting to Date" in { - - val EST = java.util.TimeZone.getTimeZone("EST") - val PST = java.util.TimeZone.getTimeZone("PST") - - val timestamp = { - import java.util.Calendar - val cal = Calendar.getInstance(EST) - cal.set(Calendar.HOUR_OF_DAY, 1) - Time(cal.getTime().getTime()) - } - - textualDate(EST)(timestamp) should not be textualDate(PST)(timestamp) - timestamp.toDate(EST) should not be timestamp.toDate(PST) - } - - "TimeOfDay" should "be created from valid strings and convert to java.sql.Time" in { - val s = "07:30:45" - val defaultTimeZone = TimeZone.getDefault() - val todFactory = TimeOfDay.parseTimeString(defaultTimeZone)(_) - val tod = todFactory(s) - tod.timeString shouldBe s - tod.timeZoneString shouldBe defaultTimeZone.getID - val sqlTime = tod.toTime - sqlTime.toLocalTime shouldBe tod.localTime - a[java.time.format.DateTimeParseException] should be thrownBy { - val illegal = "7:15" - todFactory(illegal) - } - } - - "TimeOfDay" should "have correct temporal relationships" in { - val s = "07:30:45" - val t = "09:30:45" - val pst = TimeZone.getTimeZone("America/Los_Angeles") - val est = TimeZone.getTimeZone("America/New_York") - val pstTodFactory = TimeOfDay.parseTimeString(pst)(_) - val estTodFactory = TimeOfDay.parseTimeString(est)(_) - val day = 1 - val month = Month.JANUARY - val year = 2018 - val sTodPst = pstTodFactory(s) - val sTodPst2 = pstTodFactory(s) - val tTodPst = pstTodFactory(t) - val tTodEst = estTodFactory(t) - sTodPst.isBefore(tTodPst, day, month, year) shouldBe true - tTodPst.isAfter(sTodPst, day, month, year) shouldBe true - tTodEst.isBefore(sTodPst, day, month, year) shouldBe true - sTodPst.sameTimeAs(sTodPst2, day, month, year) shouldBe true - } -} diff --git a/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala b/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala index 86cf8b5..cc0019a 100644 --- a/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala +++ b/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala @@ -8,7 +8,6 @@ import akka.http.scaladsl.server.{Directives, RejectionHandler, Route} import akka.http.scaladsl.testkit.ScalatestRouteTest import com.typesafe.scalalogging.Logger import org.scalatest.{AsyncFlatSpec, Matchers} -import xyz.driver.core.FutureExtensions import xyz.driver.core.json.serviceExceptionFormat import xyz.driver.core.logging.NoLogger import xyz.driver.core.rest.errors._ diff --git a/src/test/scala/xyz/driver/core/tagging/TaggingTest.scala b/src/test/scala/xyz/driver/core/tagging/TaggingTest.scala deleted file mode 100644 index 14dfaf9..0000000 --- a/src/test/scala/xyz/driver/core/tagging/TaggingTest.scala +++ /dev/null @@ -1,63 +0,0 @@ -package xyz.driver.core.tagging - -import org.scalatest.{Matchers, WordSpec} -import xyz.driver.core.{@@, Name} - -/** - * @author sergey - * @since 9/11/18 - */ -class TaggingTest extends WordSpec with Matchers { - - "@@ Trimmed" should { - "produce values transparently from Strings and Names (by default)" in { - val s: String @@ Trimmed = " trimmed " - val n: Name[Int] @@ Trimmed = Name(" trimmed ") - - s shouldBe "trimmed" - n shouldBe Name[Int]("trimmed") - } - - "produce values transparently from values that have an implicit conversion defined" in { - import scala.language.implicitConversions - implicit def stringSeq2Trimmed(stringSeq: Seq[String]): Seq[String] @@ Trimmed = - stringSeq.map(_.trim()).tagged[Trimmed] - - val strings: Seq[String] @@ Trimmed = Seq(" trimmed1 ", " trimmed2 ") - strings shouldBe Seq("trimmed1", "trimmed2") - } - - "produce values transparently from Options of values that have Trimmed implicits" in { - val maybeStringDirect: Option[String @@ Trimmed] = Some(" trimmed ") - val maybeStringFromMap: Option[String @@ Trimmed] = Map("s" -> " trimmed ").get("s") - - val maybeNameDirect: Option[Name[Int] @@ Trimmed] = Some(Name(" trimmed ")) - val maybeNameFromMap: Option[Name[Int] @@ Trimmed] = Map("s" -> Name[Int](" trimmed ")).get("s") - - maybeStringDirect shouldBe Some("trimmed") - maybeStringFromMap shouldBe Some("trimmed") - maybeNameDirect shouldBe Some(Name[Int]("trimmed")) - maybeNameFromMap shouldBe Some(Name[Int]("trimmed")) - } - - "produce values transparently from collections of values that have Trimmed implicits" in { - val strings = Seq("s" -> " trimmed1 ", "s" -> " trimmed2 ") - val names = strings.map { - case (k, v) => k -> Name[Int](v) - } - - val trimmedStrings: Seq[String @@ Trimmed] = strings.groupBy(_._1)("s").map(_._2) - val trimmedNames: Seq[Name[Int] @@ Trimmed] = names.groupBy(_._1)("s").map(_._2) - - trimmedStrings shouldBe Seq("trimmed1", "trimmed2") - trimmedNames shouldBe Seq("trimmed1", "trimmed2").map(Name[Int]) - } - - "have Ordering" in { - val names: Seq[Name[Int] @@ Trimmed] = Seq(" 2 ", " 1 ", "3").map(Name[Int]) - - names.sorted should contain inOrderOnly (Name("1"), Name("2"), Name("3")) - } - } - -} -- cgit v1.2.3 From 616e62e733dbbd4e6bacc5f563deef534794dc9e Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Wed, 12 Sep 2018 16:03:17 -0700 Subject: Move reporting into separate project --- .../driver/core/reporting/GoogleMdcLogger.scala | 14 ++ .../xyz/driver/core/reporting/GoogleReporter.scala | 217 +++++++++++++++++++++ .../xyz/driver/core/reporting/NoReporter.scala | 8 + .../driver/core/reporting/NoTraceReporter.scala | 20 ++ .../scala/xyz/driver/core/reporting/Reporter.scala | 183 +++++++++++++++++ .../driver/core/reporting/ScalaLoggingCompat.scala | 36 ++++ .../xyz/driver/core/reporting/SpanContext.scala | 13 ++ .../src/main/scala/xyz/driver/core/Refresh.scala | 69 +++++++ src/main/scala/xyz/driver/core/Refresh.scala | 69 ------- .../driver/core/reporting/GoogleMdcLogger.scala | 14 -- .../xyz/driver/core/reporting/GoogleReporter.scala | 217 --------------------- .../xyz/driver/core/reporting/NoReporter.scala | 8 - .../driver/core/reporting/NoTraceReporter.scala | 20 -- .../scala/xyz/driver/core/reporting/Reporter.scala | 183 ----------------- .../driver/core/reporting/ScalaLoggingCompat.scala | 36 ---- .../xyz/driver/core/reporting/SpanContext.scala | 13 -- 16 files changed, 560 insertions(+), 560 deletions(-) create mode 100644 core-reporting/src/main/scala/xyz/driver/core/reporting/GoogleMdcLogger.scala create mode 100644 core-reporting/src/main/scala/xyz/driver/core/reporting/GoogleReporter.scala create mode 100644 core-reporting/src/main/scala/xyz/driver/core/reporting/NoReporter.scala create mode 100644 core-reporting/src/main/scala/xyz/driver/core/reporting/NoTraceReporter.scala create mode 100644 core-reporting/src/main/scala/xyz/driver/core/reporting/Reporter.scala create mode 100644 core-reporting/src/main/scala/xyz/driver/core/reporting/ScalaLoggingCompat.scala create mode 100644 core-reporting/src/main/scala/xyz/driver/core/reporting/SpanContext.scala create mode 100644 core-util/src/main/scala/xyz/driver/core/Refresh.scala delete mode 100644 src/main/scala/xyz/driver/core/Refresh.scala delete mode 100644 src/main/scala/xyz/driver/core/reporting/GoogleMdcLogger.scala delete mode 100644 src/main/scala/xyz/driver/core/reporting/GoogleReporter.scala delete mode 100644 src/main/scala/xyz/driver/core/reporting/NoReporter.scala delete mode 100644 src/main/scala/xyz/driver/core/reporting/NoTraceReporter.scala delete mode 100644 src/main/scala/xyz/driver/core/reporting/Reporter.scala delete mode 100644 src/main/scala/xyz/driver/core/reporting/ScalaLoggingCompat.scala delete mode 100644 src/main/scala/xyz/driver/core/reporting/SpanContext.scala diff --git a/core-reporting/src/main/scala/xyz/driver/core/reporting/GoogleMdcLogger.scala b/core-reporting/src/main/scala/xyz/driver/core/reporting/GoogleMdcLogger.scala new file mode 100644 index 0000000..f5c41cf --- /dev/null +++ b/core-reporting/src/main/scala/xyz/driver/core/reporting/GoogleMdcLogger.scala @@ -0,0 +1,14 @@ +package xyz.driver.core +package reporting + +import org.slf4j.MDC + +trait GoogleMdcLogger extends Reporter { self: GoogleReporter => + + abstract override def log(severity: Reporter.Severity, message: String, reason: Option[Throwable])( + implicit ctx: SpanContext): Unit = { + MDC.put("trace", s"projects/${credentials.getProjectId}/traces/${ctx.traceId}") + super.log(severity, message, reason) + } + +} diff --git a/core-reporting/src/main/scala/xyz/driver/core/reporting/GoogleReporter.scala b/core-reporting/src/main/scala/xyz/driver/core/reporting/GoogleReporter.scala new file mode 100644 index 0000000..14c4954 --- /dev/null +++ b/core-reporting/src/main/scala/xyz/driver/core/reporting/GoogleReporter.scala @@ -0,0 +1,217 @@ +package xyz.driver.core +package reporting + +import java.security.Signature +import java.time.Instant +import java.util + +import akka.NotUsed +import akka.stream.scaladsl.{Flow, RestartSink, Sink, Source, SourceQueueWithComplete} +import akka.stream.{Materializer, OverflowStrategy} +import com.google.auth.oauth2.ServiceAccountCredentials +import com.softwaremill.sttp._ +import spray.json.DerivedJsonProtocol._ +import spray.json._ +import xyz.driver.core.reporting.Reporter.CausalRelation + +import scala.async.Async._ +import scala.concurrent.duration._ +import scala.concurrent.{ExecutionContext, Future} +import scala.util.control.NonFatal +import scala.util.{Failure, Random, Success, Try} + +/** A reporter that collects traces and submits them to + * [[https://cloud.google.com/trace/docs/reference/v2/rest/ Google's Stackdriver Trace API]]. + */ +class GoogleReporter( + val credentials: ServiceAccountCredentials, + namespace: String, + buffer: Int = GoogleReporter.DefaultBufferSize, + interval: FiniteDuration = GoogleReporter.DefaultInterval)( + implicit client: SttpBackend[Future, _], + mat: Materializer, + ec: ExecutionContext +) extends Reporter { + import GoogleReporter._ + + private val getToken: () => Future[String] = Refresh.every(55.minutes) { + def jwt = { + val now = Instant.now().getEpochSecond + val base64 = util.Base64.getEncoder + val header = base64.encodeToString("""{"alg":"RS256","typ":"JWT"}""".getBytes("utf-8")) + val body = base64.encodeToString( + s"""|{ + | "iss": "${credentials.getClientEmail}", + | "scope": "https://www.googleapis.com/auth/trace.append", + | "aud": "https://www.googleapis.com/oauth2/v4/token", + | "exp": ${now + 60.minutes.toSeconds}, + | "iat": $now + |}""".stripMargin.getBytes("utf-8") + ) + val signer = Signature.getInstance("SHA256withRSA") + signer.initSign(credentials.getPrivateKey) + signer.update(s"$header.$body".getBytes("utf-8")) + val signature = base64.encodeToString(signer.sign()) + s"$header.$body.$signature" + } + sttp + .post(uri"https://www.googleapis.com/oauth2/v4/token") + .body( + "grant_type" -> "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion" -> jwt + ) + .mapResponse(s => s.parseJson.asJsObject.fields("access_token").convertTo[String]) + .send() + .map(_.unsafeBody) + } + + private val sendToGoogle: Sink[Span, NotUsed] = RestartSink.withBackoff( + minBackoff = 3.seconds, + maxBackoff = 30.seconds, + randomFactor = 0.2 // adds 20% "noise" to vary the intervals slightly + ) { () => + Flow[Span] + .groupedWithin(buffer, interval) + .mapAsync(1) { spans => + async { + val token = await(getToken()) + val res = await( + sttp + .post(uri"https://cloudtrace.googleapis.com/v2/projects/${credentials.getProjectId}/traces:batchWrite") + .auth + .bearer(token) + .body( + Spans(spans).toJson.compactPrint + ) + .send() + .map(_.unsafeBody)) + res + } + } + .recover { + case NonFatal(e) => + System.err.println(s"Error submitting trace spans: $e") // scalastyle: ignore + throw e + } + .to(Sink.ignore) + } + private val queue: SourceQueueWithComplete[Span] = Source + .queue[Span](buffer, OverflowStrategy.dropHead) + .to(sendToGoogle) + .run() + + private def submit(span: Span): Unit = queue.offer(span).failed.map { e => + System.err.println(s"Error adding span to submission queue: $e") + } + + private def startSpan( + traceId: String, + spanId: String, + parentSpanId: Option[String], + displayName: String, + attributes: Map[String, String]) = Span( + s"projects/${credentials.getProjectId}/traces/$traceId/spans/$spanId", + spanId, + parentSpanId, + TruncatableString(displayName), + Instant.now(), + Instant.now(), + Attributes(attributes ++ Map("service.namespace" -> namespace)), + Failure(new IllegalStateException("span status not set")) + ) + + def traceWithOptionalParent[A]( + operationName: String, + tags: Map[String, String], + parent: Option[(SpanContext, CausalRelation)])(operation: SpanContext => A): A = { + val child = parent match { + case Some((p, _)) => SpanContext(p.traceId, f"${Random.nextLong()}%016x") + case None => SpanContext.fresh() + } + val span = startSpan(child.traceId, child.spanId, parent.map(_._1.spanId), operationName, tags) + val result = operation(child) + span.endTime = Instant.now() + submit(span) + result + } + + def traceWithOptionalParentAsync[A]( + operationName: String, + tags: Map[String, String], + parent: Option[(SpanContext, CausalRelation)])(operation: SpanContext => Future[A]): Future[A] = { + val child = parent match { + case Some((p, _)) => SpanContext(p.traceId, f"${Random.nextLong()}%016x") + case None => SpanContext.fresh() + } + val span = startSpan(child.traceId, child.spanId, parent.map(_._1.spanId), operationName, tags) + val result = operation(child) + result.onComplete { result => + span.endTime = Instant.now() + span.status = result + submit(span) + } + result + } + + override def log(severity: Reporter.Severity, message: String, reason: Option[Throwable])( + implicit ctx: SpanContext): Unit = + sys.error("Submitting logs directly to GCP is " + + "currently not supported. Messages should go to stdout.") // TODO: attach logs to traces and submit them directly + +} + +object GoogleReporter { + + val DefaultBufferSize: Int = 10000 + val DefaultInterval: FiniteDuration = 5.seconds + + private case class Attributes(attributeMap: Map[String, String]) + private case class TruncatableString(value: String) + private case class Span( + name: String, + spanId: String, + parentSpanId: Option[String], + displayName: TruncatableString, + startTime: Instant, + var endTime: Instant, + var attributes: Attributes, + var status: Try[_] + ) + + private case class Spans(spans: Seq[Span]) + + private implicit val instantFormat: RootJsonFormat[Instant] = new RootJsonFormat[Instant] { + override def write(obj: Instant): JsValue = obj.toString.toJson + override def read(json: JsValue): Instant = Instant.parse(json.convertTo[String]) + } + + private implicit val mapFormat = new RootJsonFormat[Map[String, String]] { + override def read(json: JsValue): Map[String, String] = sys.error("unimplemented") + override def write(obj: Map[String, String]): JsValue = { + val withValueObjects = obj.mapValues(value => JsObject("stringValue" -> JsObject("value" -> value.toJson))) + JsObject(withValueObjects) + } + } + + private implicit val statusFormat = new RootJsonFormat[Try[_]] { + override def read(json: JsValue): Try[_] = sys.error("unimplemented") + override def write(obj: Try[_]) = { + // error codes defined at https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto + val (code, message) = obj match { + case Success(_) => (0, "success") + case Failure(_) => (2, "failure") + } + JsObject( + "code" -> code.toJson, + "message" -> message.toJson, + "details" -> JsArray() + ) + } + } + + private implicit val attributeFormat: RootJsonFormat[Attributes] = jsonFormat1(Attributes) + private implicit val truncatableStringFormat: RootJsonFormat[TruncatableString] = jsonFormat1(TruncatableString) + private implicit val spanFormat: RootJsonFormat[Span] = jsonFormat8(Span) + private implicit val spansFormat: RootJsonFormat[Spans] = jsonFormat1(Spans) + +} diff --git a/core-reporting/src/main/scala/xyz/driver/core/reporting/NoReporter.scala b/core-reporting/src/main/scala/xyz/driver/core/reporting/NoReporter.scala new file mode 100644 index 0000000..c1c81f4 --- /dev/null +++ b/core-reporting/src/main/scala/xyz/driver/core/reporting/NoReporter.scala @@ -0,0 +1,8 @@ +package xyz.driver.core +package reporting + +trait NoReporter extends NoTraceReporter { + override def log(severity: Reporter.Severity, message: String, reason: Option[Throwable])( + implicit ctx: SpanContext): Unit = () +} +object NoReporter extends NoReporter diff --git a/core-reporting/src/main/scala/xyz/driver/core/reporting/NoTraceReporter.scala b/core-reporting/src/main/scala/xyz/driver/core/reporting/NoTraceReporter.scala new file mode 100644 index 0000000..b49cfda --- /dev/null +++ b/core-reporting/src/main/scala/xyz/driver/core/reporting/NoTraceReporter.scala @@ -0,0 +1,20 @@ +package xyz.driver.core +package reporting + +import scala.concurrent.Future + +/** A reporter mixin that does not emit traces. */ +trait NoTraceReporter extends Reporter { + + override def traceWithOptionalParent[A]( + name: String, + tags: Map[String, String], + parent: Option[(SpanContext, Reporter.CausalRelation)])(op: SpanContext => A): A = op(SpanContext.fresh()) + + override def traceWithOptionalParentAsync[A]( + name: String, + tags: Map[String, String], + parent: Option[(SpanContext, Reporter.CausalRelation)])(op: SpanContext => Future[A]): Future[A] = + op(SpanContext.fresh()) + +} diff --git a/core-reporting/src/main/scala/xyz/driver/core/reporting/Reporter.scala b/core-reporting/src/main/scala/xyz/driver/core/reporting/Reporter.scala new file mode 100644 index 0000000..469084c --- /dev/null +++ b/core-reporting/src/main/scala/xyz/driver/core/reporting/Reporter.scala @@ -0,0 +1,183 @@ +package xyz.driver.core +package reporting + +import scala.concurrent.Future + +/** Context-aware diagnostic utility for distributed systems, combining logging and tracing. + * + * Diagnostic messages (i.e. logs) are a vital tool for monitoring applications. Tying such messages to an + * execution context, such as a stack trace, simplifies debugging greatly by giving insight to the chains of events + * that led to a particular message. In synchronous systems, execution contexts can easily be determined by an + * external observer, and, as such, do not need to be propagated explicitly to sub-components (e.g. a stack trace on + * the JVM shows all relevant information). In asynchronous systems and especially distributed systems however, + * execution contexts are not easily determined by an external observer and hence need to be explicitly passed across + * service boundaries. + * + * This reporter provides tracing and logging utilities that explicitly require references to execution contexts + * (called [[SpanContext]]s here) intended to be passed across service boundaries. It embraces Scala's + * implicit-parameter-as-a-context paradigm. + * + * Tracing is intended to be compatible with the + * [[https://github.com/opentracing/specification/blob/master/specification.md OpenTrace specification]], and hence its + * guidelines on naming and tagging apply to methods provided by this Reporter as well. + * + * Usage example: + * {{{ + * val reporter: Reporter = ??? + * object Repo { + * def getUserQuery(userId: String)(implicit ctx: SpanContext) = reporter.trace("query"){ implicit ctx => + * reporter.debug("Running query") + * // run query + * } + * } + * object Service { + * def getUser(userId: String)(implicit ctx: SpanContext) = reporter.trace("get_user"){ implicit ctx => + * reporter.debug("Getting user") + * Repo.getUserQuery(userId) + * } + * } + * reporter.traceRoot("static_get", Map("user" -> "john")) { implicit ctx => + * Service.getUser("john") + * } + * }}} + * + * '''Note that computing traces may be a more expensive operation than traditional logging frameworks provide (in terms + * of memory and processing). It should be used in interesting and actionable code paths.''' + * + * @define rootWarning Note: the idea of the reporting framework is to pass along references to traces as + * implicit parameters. This method should only be used for top-level traces when no parent + * traces are available. + */ +trait Reporter { + import Reporter._ + + def traceWithOptionalParent[A]( + name: String, + tags: Map[String, String], + parent: Option[(SpanContext, CausalRelation)])(op: SpanContext => A): A + def traceWithOptionalParentAsync[A]( + name: String, + tags: Map[String, String], + parent: Option[(SpanContext, CausalRelation)])(op: SpanContext => Future[A]): Future[A] + + /** Trace the execution of an operation, if no parent trace is available. + * + * $rootWarning + */ + final def traceRoot[A](name: String, tags: Map[String, String] = Map.empty)(op: SpanContext => A): A = + traceWithOptionalParent( + name, + tags, + None + )(op) + + /** Trace the execution of an asynchronous operation, if no parent trace is available. + * + * $rootWarning + * + * @see traceRoot + */ + final def traceRootAsync[A](name: String, tags: Map[String, String] = Map.empty)( + op: SpanContext => Future[A]): Future[A] = + traceWithOptionalParentAsync( + name, + tags, + None + )(op) + + /** Trace the execution of an operation, in relation to a parent context. + * + * @param name The name of the operation. Note that this name should not be too specific. According to the + * OpenTrace RFC: "An operation name, a human-readable string which concisely represents the work done + * by the Span (for example, an RPC method name, a function name, or the name of a subtask or stage + * within a larger computation). The operation name should be the most general string that identifies a + * (statistically) interesting class of Span instances. That is, `"get_user"` is better than + * `"get_user/314159"`". + * @param tags Attributes associated with the traced event. Following the above example, if `"get_user"` is an + * operation name, a good tag would be `("account_id" -> 314159)`. + * @param relation Relation of the operation to its parent context. + * @param op The operation to be traced. The trace will complete once the operation returns. + * @param ctx Context of the parent trace. + * @tparam A Return type of the operation. + * @return The value of the child operation. + */ + final def trace[A]( + name: String, + tags: Map[String, String] = Map.empty, + relation: CausalRelation = CausalRelation.Child)(op: /* implicit (gotta wait for Scala 3) */ SpanContext => A)( + implicit ctx: SpanContext): A = + traceWithOptionalParent( + name, + tags, + Some(ctx -> relation) + )(op) + + /** Trace the operation of an asynchronous operation. + * + * Contrary to the synchronous version of this method, a trace is completed once the child operation completes + * (rather than returns). + * + * @see trace + */ + final def traceAsync[A]( + name: String, + tags: Map[String, String] = Map.empty, + relation: CausalRelation = CausalRelation.Child)( + op: /* implicit (gotta wait for Scala 3) */ SpanContext => Future[A])(implicit ctx: SpanContext): Future[A] = + traceWithOptionalParentAsync( + name, + tags, + Some(ctx -> relation) + )(op) + + /** Log a message. */ + def log(severity: Severity, message: String, reason: Option[Throwable])(implicit ctx: SpanContext): Unit + + /** Log a debug message. */ + final def debug(message: String)(implicit ctx: SpanContext): Unit = log(Severity.Debug, message, None) + final def debug(message: String, reason: Throwable)(implicit ctx: SpanContext): Unit = + log(Severity.Debug, message, Some(reason)) + + /** Log an informational message. */ + final def info(message: String)(implicit ctx: SpanContext): Unit = log(Severity.Informational, message, None) + final def info(message: String, reason: Throwable)(implicit ctx: SpanContext): Unit = + log(Severity.Informational, message, Some(reason)) + + /** Log a warning message. */ + final def warn(message: String)(implicit ctx: SpanContext): Unit = log(Severity.Warning, message, None) + final def warn(message: String, reason: Throwable)(implicit ctx: SpanContext): Unit = + log(Severity.Warning, message, Some(reason)) + + /** Log an error message. */ + final def error(message: String)(implicit ctx: SpanContext): Unit = log(Severity.Error, message, None) + final def error(message: String, reason: Throwable)(implicit ctx: SpanContext): Unit = + log(Severity.Error, message, Some(reason)) + +} + +object Reporter { + + /** A relation in cause. + * + * Corresponds to + * [[https://github.com/opentracing/specification/blob/master/specification.md#references-between-spans OpenTrace references between spans]] + */ + sealed trait CausalRelation + object CausalRelation { + + /** One event is the child of another. The parent completes once the child is complete. */ + case object Child extends CausalRelation + + /** One event follows from another, not necessarily being the parent. */ + case object Follows extends CausalRelation + } + + sealed trait Severity + object Severity { + case object Debug extends Severity + case object Informational extends Severity + case object Warning extends Severity + case object Error extends Severity + } + +} diff --git a/core-reporting/src/main/scala/xyz/driver/core/reporting/ScalaLoggingCompat.scala b/core-reporting/src/main/scala/xyz/driver/core/reporting/ScalaLoggingCompat.scala new file mode 100644 index 0000000..0ff5574 --- /dev/null +++ b/core-reporting/src/main/scala/xyz/driver/core/reporting/ScalaLoggingCompat.scala @@ -0,0 +1,36 @@ +package xyz.driver.core +package reporting + +import com.typesafe.scalalogging.{Logger => ScalaLogger} + +/** Compatibility mixin for reporters, that enables implicit conversions to scala-logging loggers. */ +trait ScalaLoggingCompat extends Reporter { + import Reporter.Severity + + def logger: ScalaLogger + + override def log(severity: Severity, message: String, reason: Option[Throwable])(implicit ctx: SpanContext): Unit = + severity match { + case Severity.Debug => logger.debug(message, reason.orNull) + case Severity.Informational => logger.info(message, reason.orNull) + case Severity.Warning => logger.warn(message, reason.orNull) + case Severity.Error => logger.error(message, reason.orNull) + } + +} + +object ScalaLoggingCompat { + import scala.language.implicitConversions + + def defaultScalaLogger(json: Boolean = false): ScalaLogger = { + if (json) { + System.setProperty("logback.configurationFile", "deployed-logback.xml") + } else { + System.setProperty("logback.configurationFile", "logback.xml") + } + ScalaLogger.apply("application") + } + + implicit def toScalaLogger(logger: ScalaLoggingCompat): ScalaLogger = logger.logger + +} diff --git a/core-reporting/src/main/scala/xyz/driver/core/reporting/SpanContext.scala b/core-reporting/src/main/scala/xyz/driver/core/reporting/SpanContext.scala new file mode 100644 index 0000000..04a822d --- /dev/null +++ b/core-reporting/src/main/scala/xyz/driver/core/reporting/SpanContext.scala @@ -0,0 +1,13 @@ +package xyz.driver.core +package reporting + +import scala.util.Random + +case class SpanContext private[core] (traceId: String, spanId: String) + +object SpanContext { + def fresh(): SpanContext = SpanContext( + f"${Random.nextLong()}%016x${Random.nextLong()}%016x", + f"${Random.nextLong()}%016x" + ) +} diff --git a/core-util/src/main/scala/xyz/driver/core/Refresh.scala b/core-util/src/main/scala/xyz/driver/core/Refresh.scala new file mode 100644 index 0000000..6db9c26 --- /dev/null +++ b/core-util/src/main/scala/xyz/driver/core/Refresh.scala @@ -0,0 +1,69 @@ +package xyz.driver.core + +import java.time.Instant +import java.util.concurrent.atomic.AtomicReference + +import scala.concurrent.{ExecutionContext, Future, Promise} +import scala.concurrent.duration.Duration + +/** A single-value asynchronous cache with TTL. + * + * Slightly adapted from + * [[https://github.com/twitter/util/blob/ae0ab09134414438af9dfaa88a4613cecbff4741/util-cache/src/main/scala/com/twitter/cache/Refresh.scala + * Twitter's "util" library]] + * + * Released under the Apache License 2.0. + */ +object Refresh { + + /** Creates a function that will provide a cached value for a given time-to-live (TTL). + * + * It avoids the "thundering herd" problem if multiple requests arrive + * simultanously and the cached value has expired or is unset. + * + * Usage example: + * {{{ + * def freshToken(): Future[String] = // expensive network call to get an access token + * val getToken: () => Future[String] = Refresh.every(1.hour)(freshToken()) + * + * getToken() // new token is issued + * getToken() // subsequent calls use the cached token + * // wait 1 hour + * getToken() // new token is issued + * }}} + * + * @param ttl Time-To-Live duration to cache a computed value. + * @param compute Call-by-name operation that eventually computes a value to + * be cached. Note that if the computation (i.e. the future) fails, the value + * is not cached. + * @param ec The execution context in which valeu computations will be run. + * @return A zero-arg function that returns the cached value. + */ + def every[A](ttl: Duration)(compute: => Future[A])(implicit ec: ExecutionContext): () => Future[A] = { + val ref = new AtomicReference[(Future[A], Instant)]( + (Future.failed(new NoSuchElementException("Cached value was never computed")), Instant.MIN) + ) + def refresh(): Future[A] = { + val tuple = ref.get + val (cachedValue, lastRetrieved) = tuple + val now = Instant.now + if (now.getEpochSecond < lastRetrieved.getEpochSecond + ttl.toSeconds) { + cachedValue + } else { + val p = Promise[A] + val nextTuple = (p.future, now) + if (ref.compareAndSet(tuple, nextTuple)) { + compute.onComplete { done => + if (done.isFailure) { + ref.set((p.future, lastRetrieved)) // don't update retrieval time in case of failure + } + p.complete(done) + } + } + refresh() + } + } + refresh _ + } + +} diff --git a/src/main/scala/xyz/driver/core/Refresh.scala b/src/main/scala/xyz/driver/core/Refresh.scala deleted file mode 100644 index 6db9c26..0000000 --- a/src/main/scala/xyz/driver/core/Refresh.scala +++ /dev/null @@ -1,69 +0,0 @@ -package xyz.driver.core - -import java.time.Instant -import java.util.concurrent.atomic.AtomicReference - -import scala.concurrent.{ExecutionContext, Future, Promise} -import scala.concurrent.duration.Duration - -/** A single-value asynchronous cache with TTL. - * - * Slightly adapted from - * [[https://github.com/twitter/util/blob/ae0ab09134414438af9dfaa88a4613cecbff4741/util-cache/src/main/scala/com/twitter/cache/Refresh.scala - * Twitter's "util" library]] - * - * Released under the Apache License 2.0. - */ -object Refresh { - - /** Creates a function that will provide a cached value for a given time-to-live (TTL). - * - * It avoids the "thundering herd" problem if multiple requests arrive - * simultanously and the cached value has expired or is unset. - * - * Usage example: - * {{{ - * def freshToken(): Future[String] = // expensive network call to get an access token - * val getToken: () => Future[String] = Refresh.every(1.hour)(freshToken()) - * - * getToken() // new token is issued - * getToken() // subsequent calls use the cached token - * // wait 1 hour - * getToken() // new token is issued - * }}} - * - * @param ttl Time-To-Live duration to cache a computed value. - * @param compute Call-by-name operation that eventually computes a value to - * be cached. Note that if the computation (i.e. the future) fails, the value - * is not cached. - * @param ec The execution context in which valeu computations will be run. - * @return A zero-arg function that returns the cached value. - */ - def every[A](ttl: Duration)(compute: => Future[A])(implicit ec: ExecutionContext): () => Future[A] = { - val ref = new AtomicReference[(Future[A], Instant)]( - (Future.failed(new NoSuchElementException("Cached value was never computed")), Instant.MIN) - ) - def refresh(): Future[A] = { - val tuple = ref.get - val (cachedValue, lastRetrieved) = tuple - val now = Instant.now - if (now.getEpochSecond < lastRetrieved.getEpochSecond + ttl.toSeconds) { - cachedValue - } else { - val p = Promise[A] - val nextTuple = (p.future, now) - if (ref.compareAndSet(tuple, nextTuple)) { - compute.onComplete { done => - if (done.isFailure) { - ref.set((p.future, lastRetrieved)) // don't update retrieval time in case of failure - } - p.complete(done) - } - } - refresh() - } - } - refresh _ - } - -} diff --git a/src/main/scala/xyz/driver/core/reporting/GoogleMdcLogger.scala b/src/main/scala/xyz/driver/core/reporting/GoogleMdcLogger.scala deleted file mode 100644 index f5c41cf..0000000 --- a/src/main/scala/xyz/driver/core/reporting/GoogleMdcLogger.scala +++ /dev/null @@ -1,14 +0,0 @@ -package xyz.driver.core -package reporting - -import org.slf4j.MDC - -trait GoogleMdcLogger extends Reporter { self: GoogleReporter => - - abstract override def log(severity: Reporter.Severity, message: String, reason: Option[Throwable])( - implicit ctx: SpanContext): Unit = { - MDC.put("trace", s"projects/${credentials.getProjectId}/traces/${ctx.traceId}") - super.log(severity, message, reason) - } - -} diff --git a/src/main/scala/xyz/driver/core/reporting/GoogleReporter.scala b/src/main/scala/xyz/driver/core/reporting/GoogleReporter.scala deleted file mode 100644 index 14c4954..0000000 --- a/src/main/scala/xyz/driver/core/reporting/GoogleReporter.scala +++ /dev/null @@ -1,217 +0,0 @@ -package xyz.driver.core -package reporting - -import java.security.Signature -import java.time.Instant -import java.util - -import akka.NotUsed -import akka.stream.scaladsl.{Flow, RestartSink, Sink, Source, SourceQueueWithComplete} -import akka.stream.{Materializer, OverflowStrategy} -import com.google.auth.oauth2.ServiceAccountCredentials -import com.softwaremill.sttp._ -import spray.json.DerivedJsonProtocol._ -import spray.json._ -import xyz.driver.core.reporting.Reporter.CausalRelation - -import scala.async.Async._ -import scala.concurrent.duration._ -import scala.concurrent.{ExecutionContext, Future} -import scala.util.control.NonFatal -import scala.util.{Failure, Random, Success, Try} - -/** A reporter that collects traces and submits them to - * [[https://cloud.google.com/trace/docs/reference/v2/rest/ Google's Stackdriver Trace API]]. - */ -class GoogleReporter( - val credentials: ServiceAccountCredentials, - namespace: String, - buffer: Int = GoogleReporter.DefaultBufferSize, - interval: FiniteDuration = GoogleReporter.DefaultInterval)( - implicit client: SttpBackend[Future, _], - mat: Materializer, - ec: ExecutionContext -) extends Reporter { - import GoogleReporter._ - - private val getToken: () => Future[String] = Refresh.every(55.minutes) { - def jwt = { - val now = Instant.now().getEpochSecond - val base64 = util.Base64.getEncoder - val header = base64.encodeToString("""{"alg":"RS256","typ":"JWT"}""".getBytes("utf-8")) - val body = base64.encodeToString( - s"""|{ - | "iss": "${credentials.getClientEmail}", - | "scope": "https://www.googleapis.com/auth/trace.append", - | "aud": "https://www.googleapis.com/oauth2/v4/token", - | "exp": ${now + 60.minutes.toSeconds}, - | "iat": $now - |}""".stripMargin.getBytes("utf-8") - ) - val signer = Signature.getInstance("SHA256withRSA") - signer.initSign(credentials.getPrivateKey) - signer.update(s"$header.$body".getBytes("utf-8")) - val signature = base64.encodeToString(signer.sign()) - s"$header.$body.$signature" - } - sttp - .post(uri"https://www.googleapis.com/oauth2/v4/token") - .body( - "grant_type" -> "urn:ietf:params:oauth:grant-type:jwt-bearer", - "assertion" -> jwt - ) - .mapResponse(s => s.parseJson.asJsObject.fields("access_token").convertTo[String]) - .send() - .map(_.unsafeBody) - } - - private val sendToGoogle: Sink[Span, NotUsed] = RestartSink.withBackoff( - minBackoff = 3.seconds, - maxBackoff = 30.seconds, - randomFactor = 0.2 // adds 20% "noise" to vary the intervals slightly - ) { () => - Flow[Span] - .groupedWithin(buffer, interval) - .mapAsync(1) { spans => - async { - val token = await(getToken()) - val res = await( - sttp - .post(uri"https://cloudtrace.googleapis.com/v2/projects/${credentials.getProjectId}/traces:batchWrite") - .auth - .bearer(token) - .body( - Spans(spans).toJson.compactPrint - ) - .send() - .map(_.unsafeBody)) - res - } - } - .recover { - case NonFatal(e) => - System.err.println(s"Error submitting trace spans: $e") // scalastyle: ignore - throw e - } - .to(Sink.ignore) - } - private val queue: SourceQueueWithComplete[Span] = Source - .queue[Span](buffer, OverflowStrategy.dropHead) - .to(sendToGoogle) - .run() - - private def submit(span: Span): Unit = queue.offer(span).failed.map { e => - System.err.println(s"Error adding span to submission queue: $e") - } - - private def startSpan( - traceId: String, - spanId: String, - parentSpanId: Option[String], - displayName: String, - attributes: Map[String, String]) = Span( - s"projects/${credentials.getProjectId}/traces/$traceId/spans/$spanId", - spanId, - parentSpanId, - TruncatableString(displayName), - Instant.now(), - Instant.now(), - Attributes(attributes ++ Map("service.namespace" -> namespace)), - Failure(new IllegalStateException("span status not set")) - ) - - def traceWithOptionalParent[A]( - operationName: String, - tags: Map[String, String], - parent: Option[(SpanContext, CausalRelation)])(operation: SpanContext => A): A = { - val child = parent match { - case Some((p, _)) => SpanContext(p.traceId, f"${Random.nextLong()}%016x") - case None => SpanContext.fresh() - } - val span = startSpan(child.traceId, child.spanId, parent.map(_._1.spanId), operationName, tags) - val result = operation(child) - span.endTime = Instant.now() - submit(span) - result - } - - def traceWithOptionalParentAsync[A]( - operationName: String, - tags: Map[String, String], - parent: Option[(SpanContext, CausalRelation)])(operation: SpanContext => Future[A]): Future[A] = { - val child = parent match { - case Some((p, _)) => SpanContext(p.traceId, f"${Random.nextLong()}%016x") - case None => SpanContext.fresh() - } - val span = startSpan(child.traceId, child.spanId, parent.map(_._1.spanId), operationName, tags) - val result = operation(child) - result.onComplete { result => - span.endTime = Instant.now() - span.status = result - submit(span) - } - result - } - - override def log(severity: Reporter.Severity, message: String, reason: Option[Throwable])( - implicit ctx: SpanContext): Unit = - sys.error("Submitting logs directly to GCP is " + - "currently not supported. Messages should go to stdout.") // TODO: attach logs to traces and submit them directly - -} - -object GoogleReporter { - - val DefaultBufferSize: Int = 10000 - val DefaultInterval: FiniteDuration = 5.seconds - - private case class Attributes(attributeMap: Map[String, String]) - private case class TruncatableString(value: String) - private case class Span( - name: String, - spanId: String, - parentSpanId: Option[String], - displayName: TruncatableString, - startTime: Instant, - var endTime: Instant, - var attributes: Attributes, - var status: Try[_] - ) - - private case class Spans(spans: Seq[Span]) - - private implicit val instantFormat: RootJsonFormat[Instant] = new RootJsonFormat[Instant] { - override def write(obj: Instant): JsValue = obj.toString.toJson - override def read(json: JsValue): Instant = Instant.parse(json.convertTo[String]) - } - - private implicit val mapFormat = new RootJsonFormat[Map[String, String]] { - override def read(json: JsValue): Map[String, String] = sys.error("unimplemented") - override def write(obj: Map[String, String]): JsValue = { - val withValueObjects = obj.mapValues(value => JsObject("stringValue" -> JsObject("value" -> value.toJson))) - JsObject(withValueObjects) - } - } - - private implicit val statusFormat = new RootJsonFormat[Try[_]] { - override def read(json: JsValue): Try[_] = sys.error("unimplemented") - override def write(obj: Try[_]) = { - // error codes defined at https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto - val (code, message) = obj match { - case Success(_) => (0, "success") - case Failure(_) => (2, "failure") - } - JsObject( - "code" -> code.toJson, - "message" -> message.toJson, - "details" -> JsArray() - ) - } - } - - private implicit val attributeFormat: RootJsonFormat[Attributes] = jsonFormat1(Attributes) - private implicit val truncatableStringFormat: RootJsonFormat[TruncatableString] = jsonFormat1(TruncatableString) - private implicit val spanFormat: RootJsonFormat[Span] = jsonFormat8(Span) - private implicit val spansFormat: RootJsonFormat[Spans] = jsonFormat1(Spans) - -} diff --git a/src/main/scala/xyz/driver/core/reporting/NoReporter.scala b/src/main/scala/xyz/driver/core/reporting/NoReporter.scala deleted file mode 100644 index c1c81f4..0000000 --- a/src/main/scala/xyz/driver/core/reporting/NoReporter.scala +++ /dev/null @@ -1,8 +0,0 @@ -package xyz.driver.core -package reporting - -trait NoReporter extends NoTraceReporter { - override def log(severity: Reporter.Severity, message: String, reason: Option[Throwable])( - implicit ctx: SpanContext): Unit = () -} -object NoReporter extends NoReporter diff --git a/src/main/scala/xyz/driver/core/reporting/NoTraceReporter.scala b/src/main/scala/xyz/driver/core/reporting/NoTraceReporter.scala deleted file mode 100644 index b49cfda..0000000 --- a/src/main/scala/xyz/driver/core/reporting/NoTraceReporter.scala +++ /dev/null @@ -1,20 +0,0 @@ -package xyz.driver.core -package reporting - -import scala.concurrent.Future - -/** A reporter mixin that does not emit traces. */ -trait NoTraceReporter extends Reporter { - - override def traceWithOptionalParent[A]( - name: String, - tags: Map[String, String], - parent: Option[(SpanContext, Reporter.CausalRelation)])(op: SpanContext => A): A = op(SpanContext.fresh()) - - override def traceWithOptionalParentAsync[A]( - name: String, - tags: Map[String, String], - parent: Option[(SpanContext, Reporter.CausalRelation)])(op: SpanContext => Future[A]): Future[A] = - op(SpanContext.fresh()) - -} diff --git a/src/main/scala/xyz/driver/core/reporting/Reporter.scala b/src/main/scala/xyz/driver/core/reporting/Reporter.scala deleted file mode 100644 index 469084c..0000000 --- a/src/main/scala/xyz/driver/core/reporting/Reporter.scala +++ /dev/null @@ -1,183 +0,0 @@ -package xyz.driver.core -package reporting - -import scala.concurrent.Future - -/** Context-aware diagnostic utility for distributed systems, combining logging and tracing. - * - * Diagnostic messages (i.e. logs) are a vital tool for monitoring applications. Tying such messages to an - * execution context, such as a stack trace, simplifies debugging greatly by giving insight to the chains of events - * that led to a particular message. In synchronous systems, execution contexts can easily be determined by an - * external observer, and, as such, do not need to be propagated explicitly to sub-components (e.g. a stack trace on - * the JVM shows all relevant information). In asynchronous systems and especially distributed systems however, - * execution contexts are not easily determined by an external observer and hence need to be explicitly passed across - * service boundaries. - * - * This reporter provides tracing and logging utilities that explicitly require references to execution contexts - * (called [[SpanContext]]s here) intended to be passed across service boundaries. It embraces Scala's - * implicit-parameter-as-a-context paradigm. - * - * Tracing is intended to be compatible with the - * [[https://github.com/opentracing/specification/blob/master/specification.md OpenTrace specification]], and hence its - * guidelines on naming and tagging apply to methods provided by this Reporter as well. - * - * Usage example: - * {{{ - * val reporter: Reporter = ??? - * object Repo { - * def getUserQuery(userId: String)(implicit ctx: SpanContext) = reporter.trace("query"){ implicit ctx => - * reporter.debug("Running query") - * // run query - * } - * } - * object Service { - * def getUser(userId: String)(implicit ctx: SpanContext) = reporter.trace("get_user"){ implicit ctx => - * reporter.debug("Getting user") - * Repo.getUserQuery(userId) - * } - * } - * reporter.traceRoot("static_get", Map("user" -> "john")) { implicit ctx => - * Service.getUser("john") - * } - * }}} - * - * '''Note that computing traces may be a more expensive operation than traditional logging frameworks provide (in terms - * of memory and processing). It should be used in interesting and actionable code paths.''' - * - * @define rootWarning Note: the idea of the reporting framework is to pass along references to traces as - * implicit parameters. This method should only be used for top-level traces when no parent - * traces are available. - */ -trait Reporter { - import Reporter._ - - def traceWithOptionalParent[A]( - name: String, - tags: Map[String, String], - parent: Option[(SpanContext, CausalRelation)])(op: SpanContext => A): A - def traceWithOptionalParentAsync[A]( - name: String, - tags: Map[String, String], - parent: Option[(SpanContext, CausalRelation)])(op: SpanContext => Future[A]): Future[A] - - /** Trace the execution of an operation, if no parent trace is available. - * - * $rootWarning - */ - final def traceRoot[A](name: String, tags: Map[String, String] = Map.empty)(op: SpanContext => A): A = - traceWithOptionalParent( - name, - tags, - None - )(op) - - /** Trace the execution of an asynchronous operation, if no parent trace is available. - * - * $rootWarning - * - * @see traceRoot - */ - final def traceRootAsync[A](name: String, tags: Map[String, String] = Map.empty)( - op: SpanContext => Future[A]): Future[A] = - traceWithOptionalParentAsync( - name, - tags, - None - )(op) - - /** Trace the execution of an operation, in relation to a parent context. - * - * @param name The name of the operation. Note that this name should not be too specific. According to the - * OpenTrace RFC: "An operation name, a human-readable string which concisely represents the work done - * by the Span (for example, an RPC method name, a function name, or the name of a subtask or stage - * within a larger computation). The operation name should be the most general string that identifies a - * (statistically) interesting class of Span instances. That is, `"get_user"` is better than - * `"get_user/314159"`". - * @param tags Attributes associated with the traced event. Following the above example, if `"get_user"` is an - * operation name, a good tag would be `("account_id" -> 314159)`. - * @param relation Relation of the operation to its parent context. - * @param op The operation to be traced. The trace will complete once the operation returns. - * @param ctx Context of the parent trace. - * @tparam A Return type of the operation. - * @return The value of the child operation. - */ - final def trace[A]( - name: String, - tags: Map[String, String] = Map.empty, - relation: CausalRelation = CausalRelation.Child)(op: /* implicit (gotta wait for Scala 3) */ SpanContext => A)( - implicit ctx: SpanContext): A = - traceWithOptionalParent( - name, - tags, - Some(ctx -> relation) - )(op) - - /** Trace the operation of an asynchronous operation. - * - * Contrary to the synchronous version of this method, a trace is completed once the child operation completes - * (rather than returns). - * - * @see trace - */ - final def traceAsync[A]( - name: String, - tags: Map[String, String] = Map.empty, - relation: CausalRelation = CausalRelation.Child)( - op: /* implicit (gotta wait for Scala 3) */ SpanContext => Future[A])(implicit ctx: SpanContext): Future[A] = - traceWithOptionalParentAsync( - name, - tags, - Some(ctx -> relation) - )(op) - - /** Log a message. */ - def log(severity: Severity, message: String, reason: Option[Throwable])(implicit ctx: SpanContext): Unit - - /** Log a debug message. */ - final def debug(message: String)(implicit ctx: SpanContext): Unit = log(Severity.Debug, message, None) - final def debug(message: String, reason: Throwable)(implicit ctx: SpanContext): Unit = - log(Severity.Debug, message, Some(reason)) - - /** Log an informational message. */ - final def info(message: String)(implicit ctx: SpanContext): Unit = log(Severity.Informational, message, None) - final def info(message: String, reason: Throwable)(implicit ctx: SpanContext): Unit = - log(Severity.Informational, message, Some(reason)) - - /** Log a warning message. */ - final def warn(message: String)(implicit ctx: SpanContext): Unit = log(Severity.Warning, message, None) - final def warn(message: String, reason: Throwable)(implicit ctx: SpanContext): Unit = - log(Severity.Warning, message, Some(reason)) - - /** Log an error message. */ - final def error(message: String)(implicit ctx: SpanContext): Unit = log(Severity.Error, message, None) - final def error(message: String, reason: Throwable)(implicit ctx: SpanContext): Unit = - log(Severity.Error, message, Some(reason)) - -} - -object Reporter { - - /** A relation in cause. - * - * Corresponds to - * [[https://github.com/opentracing/specification/blob/master/specification.md#references-between-spans OpenTrace references between spans]] - */ - sealed trait CausalRelation - object CausalRelation { - - /** One event is the child of another. The parent completes once the child is complete. */ - case object Child extends CausalRelation - - /** One event follows from another, not necessarily being the parent. */ - case object Follows extends CausalRelation - } - - sealed trait Severity - object Severity { - case object Debug extends Severity - case object Informational extends Severity - case object Warning extends Severity - case object Error extends Severity - } - -} diff --git a/src/main/scala/xyz/driver/core/reporting/ScalaLoggingCompat.scala b/src/main/scala/xyz/driver/core/reporting/ScalaLoggingCompat.scala deleted file mode 100644 index 0ff5574..0000000 --- a/src/main/scala/xyz/driver/core/reporting/ScalaLoggingCompat.scala +++ /dev/null @@ -1,36 +0,0 @@ -package xyz.driver.core -package reporting - -import com.typesafe.scalalogging.{Logger => ScalaLogger} - -/** Compatibility mixin for reporters, that enables implicit conversions to scala-logging loggers. */ -trait ScalaLoggingCompat extends Reporter { - import Reporter.Severity - - def logger: ScalaLogger - - override def log(severity: Severity, message: String, reason: Option[Throwable])(implicit ctx: SpanContext): Unit = - severity match { - case Severity.Debug => logger.debug(message, reason.orNull) - case Severity.Informational => logger.info(message, reason.orNull) - case Severity.Warning => logger.warn(message, reason.orNull) - case Severity.Error => logger.error(message, reason.orNull) - } - -} - -object ScalaLoggingCompat { - import scala.language.implicitConversions - - def defaultScalaLogger(json: Boolean = false): ScalaLogger = { - if (json) { - System.setProperty("logback.configurationFile", "deployed-logback.xml") - } else { - System.setProperty("logback.configurationFile", "logback.xml") - } - ScalaLogger.apply("application") - } - - implicit def toScalaLogger(logger: ScalaLoggingCompat): ScalaLogger = logger.logger - -} diff --git a/src/main/scala/xyz/driver/core/reporting/SpanContext.scala b/src/main/scala/xyz/driver/core/reporting/SpanContext.scala deleted file mode 100644 index 04a822d..0000000 --- a/src/main/scala/xyz/driver/core/reporting/SpanContext.scala +++ /dev/null @@ -1,13 +0,0 @@ -package xyz.driver.core -package reporting - -import scala.util.Random - -case class SpanContext private[core] (traceId: String, spanId: String) - -object SpanContext { - def fresh(): SpanContext = SpanContext( - f"${Random.nextLong()}%016x${Random.nextLong()}%016x", - f"${Random.nextLong()}%016x" - ) -} -- cgit v1.2.3 From 76db2360364a35be31414a12cbc419a534a51744 Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Wed, 12 Sep 2018 16:08:39 -0700 Subject: Specify dependencies between projects --- build.sbt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/build.sbt b/build.sbt index 576de7b..403a35f 100644 --- a/build.sbt +++ b/build.sbt @@ -62,14 +62,19 @@ lazy val `core-reporting` = project .dependsOn(`core-util`) .settings(testdeps) -lazy val `core-cloud` = project +lazy val `core-storage` = project .enablePlugins(LibraryPlugin) - .dependsOn(`core-util`) + .dependsOn(`core-reporting`) + .settings(testdeps) + +lazy val `core-messaging` = project + .enablePlugins(LibraryPlugin) + .dependsOn(`core-reporting`) .settings(testdeps) lazy val `core-init` = project .enablePlugins(LibraryPlugin) - .dependsOn(`core-util`) + .dependsOn(`core-reporting`, `core-storage`, `core-messaging`, `core-rest`) .settings(testdeps) lazy val core = project @@ -86,5 +91,5 @@ lazy val core = project s"https://github.com/drivergroup/driver-core/blob/master€{FILE_PATH}.scala" ) ) - .dependsOn(`core-types`, `core-rest`, `core-reporting`, `core-cloud`, `core-init`) + .dependsOn(`core-types`, `core-rest`, `core-reporting`, `core-storage`, `core-messaging`, `core-init`) .settings(testdeps) -- cgit v1.2.3 From 7c755c77afbd67ae2ded9d8b004736d4e27e208f Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Wed, 12 Sep 2018 16:18:26 -0700 Subject: Move storage and messaging to separate projects --- .../xyz/driver/core/messaging/AliyunBus.scala | 157 ++++++++++++ .../main/scala/xyz/driver/core/messaging/Bus.scala | 74 ++++++ .../xyz/driver/core/messaging/CreateOnDemand.scala | 49 ++++ .../xyz/driver/core/messaging/GoogleBus.scala | 267 +++++++++++++++++++++ .../scala/xyz/driver/core/messaging/QueueBus.scala | 126 ++++++++++ .../xyz/driver/core/messaging/StreamBus.scala | 102 ++++++++ .../scala/xyz/driver/core/messaging/Topic.scala | 43 ++++ .../xyz/driver/core/messaging/QueueBusTest.scala | 30 +++ .../driver/core/storage/AliyunBlobStorage.scala | 108 +++++++++ .../xyz/driver/core/storage/BlobStorage.scala | 50 ++++ .../core/storage/FileSystemBlobStorage.scala | 82 +++++++ .../xyz/driver/core/storage/GcsBlobStorage.scala | 96 ++++++++ .../xyz/driver/core/storage/channelStreams.scala | 112 +++++++++ .../scala/xyz/driver/core/BlobStorageTest.scala | 94 ++++++++ .../xyz/driver/core/messaging/AliyunBus.scala | 157 ------------ src/main/scala/xyz/driver/core/messaging/Bus.scala | 74 ------ .../xyz/driver/core/messaging/CreateOnDemand.scala | 49 ---- .../xyz/driver/core/messaging/GoogleBus.scala | 267 --------------------- .../scala/xyz/driver/core/messaging/QueueBus.scala | 126 ---------- .../xyz/driver/core/messaging/StreamBus.scala | 102 -------- .../scala/xyz/driver/core/messaging/Topic.scala | 43 ---- .../driver/core/storage/AliyunBlobStorage.scala | 108 --------- .../xyz/driver/core/storage/BlobStorage.scala | 50 ---- .../core/storage/FileSystemBlobStorage.scala | 82 ------- .../xyz/driver/core/storage/GcsBlobStorage.scala | 96 -------- .../xyz/driver/core/storage/channelStreams.scala | 112 --------- .../scala/xyz/driver/core/BlobStorageTest.scala | 94 -------- .../xyz/driver/core/messaging/QueueBusTest.scala | 30 --- 28 files changed, 1390 insertions(+), 1390 deletions(-) create mode 100644 core-messaging/src/main/scala/xyz/driver/core/messaging/AliyunBus.scala create mode 100644 core-messaging/src/main/scala/xyz/driver/core/messaging/Bus.scala create mode 100644 core-messaging/src/main/scala/xyz/driver/core/messaging/CreateOnDemand.scala create mode 100644 core-messaging/src/main/scala/xyz/driver/core/messaging/GoogleBus.scala create mode 100644 core-messaging/src/main/scala/xyz/driver/core/messaging/QueueBus.scala create mode 100644 core-messaging/src/main/scala/xyz/driver/core/messaging/StreamBus.scala create mode 100644 core-messaging/src/main/scala/xyz/driver/core/messaging/Topic.scala create mode 100644 core-messaging/src/test/scala/xyz/driver/core/messaging/QueueBusTest.scala create mode 100644 core-storage/src/main/scala/xyz/driver/core/storage/AliyunBlobStorage.scala create mode 100644 core-storage/src/main/scala/xyz/driver/core/storage/BlobStorage.scala create mode 100644 core-storage/src/main/scala/xyz/driver/core/storage/FileSystemBlobStorage.scala create mode 100644 core-storage/src/main/scala/xyz/driver/core/storage/GcsBlobStorage.scala create mode 100644 core-storage/src/main/scala/xyz/driver/core/storage/channelStreams.scala create mode 100644 core-storage/src/test/scala/xyz/driver/core/BlobStorageTest.scala delete mode 100644 src/main/scala/xyz/driver/core/messaging/AliyunBus.scala delete mode 100644 src/main/scala/xyz/driver/core/messaging/Bus.scala delete mode 100644 src/main/scala/xyz/driver/core/messaging/CreateOnDemand.scala delete mode 100644 src/main/scala/xyz/driver/core/messaging/GoogleBus.scala delete mode 100644 src/main/scala/xyz/driver/core/messaging/QueueBus.scala delete mode 100644 src/main/scala/xyz/driver/core/messaging/StreamBus.scala delete mode 100644 src/main/scala/xyz/driver/core/messaging/Topic.scala delete mode 100644 src/main/scala/xyz/driver/core/storage/AliyunBlobStorage.scala delete mode 100644 src/main/scala/xyz/driver/core/storage/BlobStorage.scala delete mode 100644 src/main/scala/xyz/driver/core/storage/FileSystemBlobStorage.scala delete mode 100644 src/main/scala/xyz/driver/core/storage/GcsBlobStorage.scala delete mode 100644 src/main/scala/xyz/driver/core/storage/channelStreams.scala delete mode 100644 src/test/scala/xyz/driver/core/BlobStorageTest.scala delete mode 100644 src/test/scala/xyz/driver/core/messaging/QueueBusTest.scala diff --git a/core-messaging/src/main/scala/xyz/driver/core/messaging/AliyunBus.scala b/core-messaging/src/main/scala/xyz/driver/core/messaging/AliyunBus.scala new file mode 100644 index 0000000..8b7bca7 --- /dev/null +++ b/core-messaging/src/main/scala/xyz/driver/core/messaging/AliyunBus.scala @@ -0,0 +1,157 @@ +package xyz.driver.core.messaging +import java.nio.ByteBuffer +import java.util + +import com.aliyun.mns.client.{AsyncCallback, CloudAccount} +import com.aliyun.mns.common.ServiceException +import com.aliyun.mns.model +import com.aliyun.mns.model._ + +import scala.concurrent.duration._ +import scala.concurrent.{ExecutionContext, Future, Promise} + +class AliyunBus( + accountId: String, + accessId: String, + accessSecret: String, + region: String, + namespace: String, + pullTimeout: Int +)(implicit val executionContext: ExecutionContext) + extends Bus { + private val endpoint = s"https://$accountId.mns.$region.aliyuncs.com" + private val cloudAccount = new CloudAccount(accessId, accessSecret, endpoint) + private val client = cloudAccount.getMNSClient + + // When calling the asyncBatchPopMessage endpoint, alicloud returns an error if no message is received before the + // pullTimeout. This error is documented as MessageNotExist, however it's been observed to return InternalServerError + // occasionally. We mask both of these errors and return an empty list of messages + private val MaskedErrorCodes: Set[String] = Set("MessageNotExist", "InternalServerError") + + override val defaultMaxMessages: Int = 10 + + case class MessageId(queueName: String, messageHandle: String) + + case class Message[A](id: MessageId, data: A) extends BasicMessage[A] + + case class SubscriptionConfig( + subscriptionPrefix: String = accessId, + ackTimeout: FiniteDuration = 10.seconds + ) + + override val defaultSubscriptionConfig: SubscriptionConfig = SubscriptionConfig() + + private def rawTopicName(topic: Topic[_]) = + s"$namespace-${topic.name}" + private def rawSubscriptionName(config: SubscriptionConfig, topic: Topic[_]) = + s"$namespace-${config.subscriptionPrefix}-${topic.name}" + + override def fetchMessages[A]( + topic: Topic[A], + config: SubscriptionConfig, + maxMessages: Int): Future[Seq[Message[A]]] = { + import collection.JavaConverters._ + val subscriptionName = rawSubscriptionName(config, topic) + val queueRef = client.getQueueRef(subscriptionName) + + val promise = Promise[Seq[model.Message]] + queueRef.asyncBatchPopMessage( + maxMessages, + pullTimeout, + new AsyncCallback[util.List[model.Message]] { + override def onSuccess(result: util.List[model.Message]): Unit = { + promise.success(result.asScala) + } + override def onFail(ex: Exception): Unit = ex match { + case serviceException: ServiceException if MaskedErrorCodes(serviceException.getErrorCode) => + promise.success(Nil) + case _ => + promise.failure(ex) + } + } + ) + + promise.future.map(_.map { message => + import scala.xml.XML + val messageId = MessageId(subscriptionName, message.getReceiptHandle) + val messageXML = XML.loadString(message.getMessageBodyAsRawString) + val messageNode = messageXML \ "Message" + val messageBytes = java.util.Base64.getDecoder.decode(messageNode.head.text) + + val deserializedMessage = topic.deserialize(ByteBuffer.wrap(messageBytes)) + Message(messageId, deserializedMessage) + }) + } + + override def acknowledgeMessages(messages: Seq[MessageId]): Future[Unit] = { + import collection.JavaConverters._ + require(messages.nonEmpty, "Acknowledged message list must be non-empty") + + val queueRef = client.getQueueRef(messages.head.queueName) + + val promise = Promise[Unit] + queueRef.asyncBatchDeleteMessage( + messages.map(_.messageHandle).asJava, + new AsyncCallback[Void] { + override def onSuccess(result: Void): Unit = promise.success(()) + override def onFail(ex: Exception): Unit = promise.failure(ex) + } + ) + + promise.future + } + + override def publishMessages[A](topic: Topic[A], messages: Seq[A]): Future[Unit] = { + val topicRef = client.getTopicRef(rawTopicName(topic)) + + val publishMessages = messages.map { message => + val promise = Promise[TopicMessage] + + val topicMessage = new Base64TopicMessage + topicMessage.setMessageBody(topic.serialize(message).array()) + + topicRef.asyncPublishMessage( + topicMessage, + new AsyncCallback[TopicMessage] { + override def onSuccess(result: TopicMessage): Unit = promise.success(result) + override def onFail(ex: Exception): Unit = promise.failure(ex) + } + ) + + promise.future + } + + Future.sequence(publishMessages).map(_ => ()) + } + + def createTopic(topic: Topic[_]): Future[Unit] = Future { + val topicName = rawTopicName(topic) + val topicExists = Option(client.listTopic(topicName, "", 1)).exists(!_.getResult.isEmpty) + if (!topicExists) { + val topicMeta = new TopicMeta + topicMeta.setTopicName(topicName) + client.createTopic(topicMeta) + } + } + + def createSubscription(topic: Topic[_], config: SubscriptionConfig): Future[Unit] = Future { + val subscriptionName = rawSubscriptionName(config, topic) + val queueExists = Option(client.listQueue(subscriptionName, "", 1)).exists(!_.getResult.isEmpty) + + if (!queueExists) { + val topicName = rawTopicName(topic) + val topicRef = client.getTopicRef(topicName) + + val queueMeta = new QueueMeta + queueMeta.setQueueName(subscriptionName) + queueMeta.setVisibilityTimeout(config.ackTimeout.toSeconds) + client.createQueue(queueMeta) + + val subscriptionMeta = new SubscriptionMeta + subscriptionMeta.setSubscriptionName(subscriptionName) + subscriptionMeta.setTopicName(topicName) + subscriptionMeta.setEndpoint(topicRef.generateQueueEndpoint(subscriptionName)) + topicRef.subscribe(subscriptionMeta) + } + } +} diff --git a/core-messaging/src/main/scala/xyz/driver/core/messaging/Bus.scala b/core-messaging/src/main/scala/xyz/driver/core/messaging/Bus.scala new file mode 100644 index 0000000..75954f4 --- /dev/null +++ b/core-messaging/src/main/scala/xyz/driver/core/messaging/Bus.scala @@ -0,0 +1,74 @@ +package xyz.driver.core +package messaging + +import scala.concurrent._ +import scala.language.higherKinds + +/** Base trait for representing message buses. + * + * Message buses are expected to provide "at least once" delivery semantics and are + * expected to retry delivery when a message remains unacknowledged for a reasonable + * amount of time. */ +trait Bus { + + /** Type of unique message identifiers. Usually a string or UUID. */ + type MessageId + + /** Most general kind of message. Any implementation of a message bus must provide + * the fields and methods specified in this trait. */ + trait BasicMessage[A] { self: Message[A] => + + /** All messages must have unique IDs so that they can be acknowledged unambiguously. */ + def id: MessageId + + /** Actual message content. */ + def data: A + + } + + /** Actual type of messages provided by this bus. This must be a subtype of BasicMessage + * (as that defines the minimal required fields of a messages), but may be refined to + * provide bus-specific additional data. */ + type Message[A] <: BasicMessage[A] + + /** Type of a bus-specific configuration object can be used to tweak settings of subscriptions. */ + type SubscriptionConfig + + /** Default value for a subscription configuration. It is such that any service will have a unique subscription + * for every topic, shared among all its instances. */ + val defaultSubscriptionConfig: SubscriptionConfig + + /** Maximum amount of messages handled in a single retrieval call. */ + val defaultMaxMessages = 64 + + /** Execution context that is used to query and dispatch messages from this bus. */ + implicit val executionContext: ExecutionContext + + /** Retrieve any new messages in the mailbox of a subscription. + * + * Any retrieved messages become "outstanding" and should not be returned by this function + * again until a reasonable (bus-specific) amount of time has passed and they remain unacknowledged. + * In that case, they will again be considered new and will be returned by this function. + * + * Note that although outstanding and acknowledged messages will eventually be removed from + * mailboxes, no guarantee can be made that a message will be delivered only once. */ + def fetchMessages[A]( + topic: Topic[A], + config: SubscriptionConfig = defaultSubscriptionConfig, + maxMessages: Int = defaultMaxMessages): Future[Seq[Message[A]]] + + /** Acknowledge that a given series of messages has been handled and should + * not be delivered again. + * + * Note that messages become eventually acknowledged and hence may be delivered more than once. + * @see fetchMessages() + */ + def acknowledgeMessages(messages: Seq[MessageId]): Future[Unit] + + /** Send a series of messages to a topic. + * + * The returned future will complete once messages have been accepted to the underlying bus. + * No guarantee can be made of their delivery to subscribers. */ + def publishMessages[A](topic: Topic[A], messages: Seq[A]): Future[Unit] + +} diff --git a/core-messaging/src/main/scala/xyz/driver/core/messaging/CreateOnDemand.scala b/core-messaging/src/main/scala/xyz/driver/core/messaging/CreateOnDemand.scala new file mode 100644 index 0000000..1af5308 --- /dev/null +++ b/core-messaging/src/main/scala/xyz/driver/core/messaging/CreateOnDemand.scala @@ -0,0 +1,49 @@ +package xyz.driver.core +package messaging + +import java.util.concurrent.ConcurrentHashMap + +import scala.async.Async.{async, await} +import scala.concurrent.Future + +/** Utility mixin that will ensure that topics and subscriptions exist before + * attempting to read or write from or to them. + */ +trait CreateOnDemand extends Bus { + + /** Create the given topic. This operation is idempotent and is expected to succeed if the topic + * already exists. + */ + def createTopic(topic: Topic[_]): Future[Unit] + + /** Create the given subscription. This operation is idempotent and is expected to succeed if the subscription + * already exists. + */ + def createSubscription(topic: Topic[_], config: SubscriptionConfig): Future[Unit] + + private val createdTopics = new ConcurrentHashMap[Topic[_], Future[Unit]] + private val createdSubscriptions = new ConcurrentHashMap[(Topic[_], SubscriptionConfig), Future[Unit]] + + private def ensureTopic(topic: Topic[_]) = + createdTopics.computeIfAbsent(topic, t => createTopic(t)) + + private def ensureSubscription(topic: Topic[_], config: SubscriptionConfig) = + createdSubscriptions.computeIfAbsent(topic -> config, { + case (t, c) => createSubscription(t, c) + }) + + abstract override def publishMessages[A](topic: Topic[A], messages: Seq[A]): Future[Unit] = async { + await(ensureTopic(topic)) + await(super.publishMessages(topic, messages)) + } + + abstract override def fetchMessages[A]( + topic: Topic[A], + config: SubscriptionConfig, + maxMessages: Int): Future[Seq[Message[A]]] = async { + await(ensureTopic(topic)) + await(ensureSubscription(topic, config)) + await(super.fetchMessages(topic, config, maxMessages)) + } + +} diff --git a/core-messaging/src/main/scala/xyz/driver/core/messaging/GoogleBus.scala b/core-messaging/src/main/scala/xyz/driver/core/messaging/GoogleBus.scala new file mode 100644 index 0000000..b296c50 --- /dev/null +++ b/core-messaging/src/main/scala/xyz/driver/core/messaging/GoogleBus.scala @@ -0,0 +1,267 @@ +package xyz.driver.core +package messaging + +import java.nio.ByteBuffer +import java.nio.file.{Files, Path, Paths} +import java.security.Signature +import java.time.Instant +import java.util + +import com.google.auth.oauth2.ServiceAccountCredentials +import com.softwaremill.sttp._ +import spray.json.DefaultJsonProtocol._ +import spray.json._ + +import scala.async.Async.{async, await} +import scala.concurrent._ +import scala.concurrent.duration._ + +/** A message bus implemented by [[https://cloud.google.com/pubsub/docs/overview Google's Pub/Sub service.]] + * + * == Overview == + * + * The Pub/Sub message system is focused around a few concepts: 'topics', + * 'subscriptions' and 'subscribers'. Messages are sent to ''topics'' which may + * have multiple ''subscriptions'' associated to them. Every subscription to a + * topic will receive all messages sent to the topic. Messages are enqueued in + * a subscription until they are acknowledged by a ''subscriber''. Multiple + * subscribers may be associated to a subscription, in which case messages will + * get delivered arbitrarily among them. + * + * Topics and subscriptions are named resources which can be specified in + * Pub/Sub's configuration and may be queried. Subscribers on the other hand, + * are ephemeral processes that query a subscription on a regular basis, handle any + * messages and acknowledge them. + * + * == Delivery semantics == + * + * - at least once + * - no ordering + * + * == Retention == + * + * - configurable retry delay for unacknowledged messages, defaults to 10s + * - undeliverable messages are kept for 7 days + * + * @param credentials Google cloud credentials, usually the same as used by a + * service. Must have admin access to topics and + * descriptions. + * @param namespace The namespace in which this bus is running. Will be used to + * determine the exact name of topics and subscriptions. + * @param pullTimeout Delay after which a call to fetchMessages() will return an + * empty list, assuming that no messages have been received. + * @param executionContext Execution context to run any blocking commands. + * @param backend sttp backend used to query Pub/Sub's HTTP API + */ +class GoogleBus( + credentials: ServiceAccountCredentials, + namespace: String, + pullTimeout: Duration = 90.seconds +)(implicit val executionContext: ExecutionContext, backend: SttpBackend[Future, _]) + extends Bus { + import GoogleBus.Protocol + + case class MessageId(subscription: String, ackId: String) + + case class PubsubMessage[A](id: MessageId, data: A, publishTime: Instant) extends super.BasicMessage[A] + type Message[A] = PubsubMessage[A] + + /** Subscription-specific configuration + * + * @param subscriptionPrefix An identifier used to uniquely determine the name of Pub/Sub subscriptions. + * All messages sent to a subscription will be dispatched arbitrarily + * among any subscribers. Defaults to the email of the credentials used by this + * bus instance, thereby giving every service a unique subscription to every topic. + * To give every service instance a unique subscription, this must be changed to a + * unique value. + * @param ackTimeout Duration in which a message must be acknowledged before it is delivered again. + */ + case class SubscriptionConfig( + subscriptionPrefix: String = credentials.getClientEmail.split("@")(0), + ackTimeout: FiniteDuration = 10.seconds + ) + override val defaultSubscriptionConfig: SubscriptionConfig = SubscriptionConfig() + + /** Obtain an authentication token valid for the given duration + * https://developers.google.com/identity/protocols/OAuth2ServiceAccount + */ + private def freshAuthToken(duration: FiniteDuration): Future[String] = { + def jwt = { + val now = Instant.now().getEpochSecond + val base64 = util.Base64.getEncoder + val header = base64.encodeToString("""{"alg":"RS256","typ":"JWT"}""".getBytes("utf-8")) + val body = base64.encodeToString( + s"""|{ + | "iss": "${credentials.getClientEmail}", + | "scope": "https://www.googleapis.com/auth/pubsub", + | "aud": "https://www.googleapis.com/oauth2/v4/token", + | "exp": ${now + duration.toSeconds}, + | "iat": $now + |}""".stripMargin.getBytes("utf-8") + ) + val signer = Signature.getInstance("SHA256withRSA") + signer.initSign(credentials.getPrivateKey) + signer.update(s"$header.$body".getBytes("utf-8")) + val signature = base64.encodeToString(signer.sign()) + s"$header.$body.$signature" + } + sttp + .post(uri"https://www.googleapis.com/oauth2/v4/token") + .body( + "grant_type" -> "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion" -> jwt + ) + .mapResponse(s => s.parseJson.asJsObject.fields("access_token").convertTo[String]) + .send() + .map(_.unsafeBody) + } + + // the token is cached a few minutes less than its validity to diminish latency of concurrent accesses at renewal time + private val getToken: () => Future[String] = Refresh.every(55.minutes)(freshAuthToken(60.minutes)) + + private val baseUri = uri"https://pubsub.googleapis.com/" + + private def rawTopicName(topic: Topic[_]) = + s"projects/${credentials.getProjectId}/topics/$namespace.${topic.name}" + private def rawSubscriptionName(config: SubscriptionConfig, topic: Topic[_]) = + s"projects/${credentials.getProjectId}/subscriptions/$namespace.${config.subscriptionPrefix}.${topic.name}" + + def createTopic(topic: Topic[_]): Future[Unit] = async { + val request = sttp + .put(baseUri.path(s"v1/${rawTopicName(topic)}")) + .auth + .bearer(await(getToken())) + val result = await(request.send()) + result.body match { + case Left(error) if result.code != 409 => // 409 <=> topic already exists, ignore it + throw new NoSuchElementException(s"Error creating topic: Status code ${result.code}: $error") + case _ => () + } + } + + def createSubscription(topic: Topic[_], config: SubscriptionConfig): Future[Unit] = async { + val request = sttp + .put(baseUri.path(s"v1/${rawSubscriptionName(config, topic)}")) + .auth + .bearer(await(getToken())) + .body( + JsObject( + "topic" -> rawTopicName(topic).toJson, + "ackDeadlineSeconds" -> config.ackTimeout.toSeconds.toJson + ).compactPrint + ) + val result = await(request.send()) + result.body match { + case Left(error) if result.code != 409 => // 409 <=> subscription already exists, ignore it + throw new NoSuchElementException(s"Error creating subscription: Status code ${result.code}: $error") + case _ => () + } + } + + override def publishMessages[A](topic: Topic[A], messages: Seq[A]): Future[Unit] = async { + import Protocol.bufferFormat + val buffers: Seq[ByteBuffer] = messages.map(topic.serialize) + val request = sttp + .post(baseUri.path(s"v1/${rawTopicName(topic)}:publish")) + .auth + .bearer(await(getToken())) + .body( + JsObject("messages" -> buffers.map(buffer => JsObject("data" -> buffer.toJson)).toJson).compactPrint + ) + await(request.send()).unsafeBody + () + } + + override def fetchMessages[A]( + topic: Topic[A], + subscriptionConfig: SubscriptionConfig, + maxMessages: Int): Future[Seq[PubsubMessage[A]]] = async { + val subscription = rawSubscriptionName(subscriptionConfig, topic) + val request = sttp + .post(baseUri.path(s"v1/$subscription:pull")) + .auth + .bearer(await(getToken().map(x => x))) + .body( + JsObject( + "returnImmediately" -> JsFalse, + "maxMessages" -> JsNumber(maxMessages) + ).compactPrint + ) + .readTimeout(pullTimeout) + .mapResponse(_.parseJson) + + val messages = await(request.send()).unsafeBody match { + case JsObject(fields) if fields.isEmpty => Seq() + case obj => obj.convertTo[Protocol.SubscriptionPull].receivedMessages + } + + messages.map { msg => + PubsubMessage[A]( + MessageId(subscription, msg.ackId), + topic.deserialize(msg.message.data), + msg.message.publishTime + ) + } + } + + override def acknowledgeMessages(messageIds: Seq[MessageId]): Future[Unit] = async { + val request = sttp + .post(baseUri.path(s"v1/${messageIds.head.subscription}:acknowledge")) + .auth + .bearer(await(getToken())) + .body( + JsObject("ackIds" -> JsArray(messageIds.toVector.map(m => JsString(m.ackId)))).compactPrint + ) + await(request.send()).unsafeBody + () + } + +} + +object GoogleBus { + + private object Protocol extends DefaultJsonProtocol { + case class SubscriptionPull(receivedMessages: Seq[ReceivedMessage]) + case class ReceivedMessage(ackId: String, message: PubsubMessage) + case class PubsubMessage(data: ByteBuffer, publishTime: Instant) + + implicit val timeFormat: JsonFormat[Instant] = new JsonFormat[Instant] { + override def write(obj: Instant): JsValue = JsString(obj.toString) + override def read(json: JsValue): Instant = Instant.parse(json.convertTo[String]) + } + implicit val bufferFormat: JsonFormat[ByteBuffer] = new JsonFormat[ByteBuffer] { + override def write(obj: ByteBuffer): JsValue = + JsString(util.Base64.getEncoder.encodeToString(obj.array())) + + override def read(json: JsValue): ByteBuffer = { + val encodedBytes = json.convertTo[String].getBytes("utf-8") + val decodedBytes = util.Base64.getDecoder.decode(encodedBytes) + ByteBuffer.wrap(decodedBytes) + } + } + + implicit val pubsubMessageFormat: RootJsonFormat[PubsubMessage] = jsonFormat2(PubsubMessage) + implicit val receivedMessageFormat: RootJsonFormat[ReceivedMessage] = jsonFormat2(ReceivedMessage) + implicit val subscrptionPullFormat: RootJsonFormat[SubscriptionPull] = jsonFormat1(SubscriptionPull) + } + + def fromKeyfile(keyfile: Path, namespace: String)( + implicit executionContext: ExecutionContext, + backend: SttpBackend[Future, _]): GoogleBus = { + val creds = ServiceAccountCredentials.fromStream(Files.newInputStream(keyfile)) + new GoogleBus(creds, namespace) + } + + @deprecated( + "Reading from the environment adds opaque dependencies and hance leads to extra complexity. Use fromKeyfile instead.", + "driver-core 1.12.2") + def fromEnv(implicit executionContext: ExecutionContext, backend: SttpBackend[Future, _]): GoogleBus = { + def env(key: String) = { + require(sys.env.contains(key), s"Environment variable $key is not set.") + sys.env(key) + } + val keyfile = Paths.get(env("GOOGLE_APPLICATION_CREDENTIALS")) + fromKeyfile(keyfile, env("SERVICE_NAMESPACE")) + } + +} diff --git a/core-messaging/src/main/scala/xyz/driver/core/messaging/QueueBus.scala b/core-messaging/src/main/scala/xyz/driver/core/messaging/QueueBus.scala new file mode 100644 index 0000000..45c9ed5 --- /dev/null +++ b/core-messaging/src/main/scala/xyz/driver/core/messaging/QueueBus.scala @@ -0,0 +1,126 @@ +package xyz.driver.core +package messaging + +import java.nio.ByteBuffer + +import akka.actor.{Actor, ActorSystem, Props} + +import scala.collection.mutable +import scala.collection.mutable.Map +import scala.concurrent.duration._ +import scala.concurrent.{Future, Promise} + +/** A bus backed by an asynchronous queue. Note that this bus requires a local actor system + * and is intended for testing purposes only. */ +class QueueBus(implicit system: ActorSystem) extends Bus { + import system.dispatcher + + override type SubscriptionConfig = Long + override val defaultSubscriptionConfig: Long = 0 + override val executionContext = system.dispatcher + + override type MessageId = (String, SubscriptionConfig, Long) + type Message[A] = BasicMessage[A] + + private object ActorMessages { + case class Send[A](topic: Topic[A], messages: Seq[A]) + case class Ack(messages: Seq[MessageId]) + case class Fetch[A](topic: Topic[A], cfg: SubscriptionConfig, max: Int, response: Promise[Seq[Message[A]]]) + case object Unack + } + + class Subscription { + val mailbox: mutable.Map[MessageId, ByteBuffer] = mutable.Map.empty + val unacked: mutable.Map[MessageId, ByteBuffer] = mutable.Map.empty + } + + private class BusMaster extends Actor { + var _id = 0L + def nextId(topic: String, cfg: SubscriptionConfig): (String, SubscriptionConfig, Long) = { + _id += 1; (topic, cfg, _id) + } + + val topics: mutable.Map[String, mutable.Map[SubscriptionConfig, Subscription]] = mutable.Map.empty + + def ensureSubscription(topic: String, cfg: SubscriptionConfig): Unit = { + topics.get(topic) match { + case Some(t) => + t.getOrElseUpdate(cfg, new Subscription) + case None => + topics += topic -> mutable.Map.empty + ensureSubscription(topic, cfg) + } + } + + override def preStart(): Unit = { + context.system.scheduler.schedule(1.seconds, 1.seconds) { + self ! ActorMessages.Unack + } + } + + override def receive: Receive = { + case ActorMessages.Send(topic, messages) => + val buffers = messages.map(topic.serialize) + val subscriptions = topics.getOrElse(topic.name, Map.empty) + for ((cfg, subscription) <- subscriptions) { + for (buffer <- buffers) { + subscription.mailbox += nextId(topic.name, cfg) -> buffer + } + } + + case ActorMessages.Fetch(topic, cfg, max, promise) => + ensureSubscription(topic.name, cfg) + val subscription = topics(topic.name)(cfg) + val messages = subscription.mailbox.take(max) + subscription.unacked ++= messages + subscription.mailbox --= messages.map(_._1) + promise.success(messages.toSeq.map { + case (ackId, buffer) => + new Message[Any] { + val id = ackId + val data = topic.deserialize(buffer) + } + }) + + case ActorMessages.Ack(messageIds) => + for (id @ (topic, cfg, _) <- messageIds) { + ensureSubscription(topic, cfg) + val subscription = topics(topic)(cfg) + subscription.unacked -= id + } + + case ActorMessages.Unack => + for ((_, subscriptions) <- topics) { + for ((_, subscription) <- subscriptions) { + subscription.mailbox ++= subscription.unacked + subscription.unacked.clear() + } + } + } + + } + + val actor = system.actorOf(Props(new BusMaster)) + + override def publishMessages[A](topic: Topic[A], messages: Seq[A]): Future[Unit] = Future { + actor ! ActorMessages.Send(topic, messages) + } + + override def fetchMessages[A]( + topic: messaging.Topic[A], + config: SubscriptionConfig, + maxMessages: Int): Future[Seq[Message[A]]] = { + val result = Promise[Seq[Message[A]]] + actor ! ActorMessages.Fetch(topic, config, maxMessages, result) + result.future + } + + override def acknowledgeMessages(ids: Seq[MessageId]): Future[Unit] = Future { + actor ! ActorMessages.Ack(ids) + } + +} + +object QueueBus { + def apply(implicit system: ActorSystem) = new QueueBus +} diff --git a/core-messaging/src/main/scala/xyz/driver/core/messaging/StreamBus.scala b/core-messaging/src/main/scala/xyz/driver/core/messaging/StreamBus.scala new file mode 100644 index 0000000..44d75cd --- /dev/null +++ b/core-messaging/src/main/scala/xyz/driver/core/messaging/StreamBus.scala @@ -0,0 +1,102 @@ +package xyz.driver.core +package messaging + +import akka.NotUsed +import akka.stream.Materializer +import akka.stream.scaladsl.{Flow, RestartSource, Sink, Source} + +import scala.collection.mutable.ListBuffer +import scala.concurrent.Future +import scala.concurrent.duration._ + +/** An extension to message buses that offers an Akka-Streams API. + * + * Example usage of a stream that subscribes to one topic, prints any messages + * it receives and finally acknowledges them + * their receipt. + * {{{ + * val bus: StreamBus = ??? + * val topic = Topic.string("topic") + * bus.subscribe(topic1) + * .map{ msg => + * print(msg.data) + * msg + * } + * .to(bus.acknowledge) + * .run() + * }}} */ +trait StreamBus extends Bus { + + /** Flow that publishes any messages to a given topic. + * Emits messages once they have been published to the underlying bus. */ + def publish[A](topic: Topic[A]): Flow[A, A, NotUsed] = { + Flow[A] + .batch(defaultMaxMessages.toLong, a => ListBuffer[A](a))(_ += _) + .mapAsync(1) { a => + publishMessages(topic, a).map(_ => a) + } + .mapConcat(list => list.toList) + } + + /** Sink that acknowledges the receipt of a message. */ + def acknowledge: Sink[MessageId, NotUsed] = { + Flow[MessageId] + .batch(defaultMaxMessages.toLong, a => ListBuffer[MessageId](a))(_ += _) + .mapAsync(1)(acknowledgeMessages(_)) + .to(Sink.ignore) + } + + /** Source that listens to a subscription and receives any messages sent to its topic. */ + def subscribe[A]( + topic: Topic[A], + config: SubscriptionConfig = defaultSubscriptionConfig): Source[Message[A], NotUsed] = { + Source + .unfoldAsync((topic, config))( + topicAndConfig => + fetchMessages(topicAndConfig._1, topicAndConfig._2, defaultMaxMessages).map(msgs => + Some(topicAndConfig -> msgs)) + ) + .filter(_.nonEmpty) + .mapConcat(messages => messages.toList) + } + + def runWithRestart[A]( + topic: Topic[A], + config: SubscriptionConfig = defaultSubscriptionConfig, + minBackoff: FiniteDuration = 3.seconds, + maxBackoff: FiniteDuration = 30.seconds, + randomFactor: Double = 0.2, + maxRestarts: Int = 20 + )(processMessage: Flow[Message[A], List[MessageId], NotUsed])(implicit mat: Materializer): NotUsed = { + RestartSource + .withBackoff[MessageId]( + minBackoff, + maxBackoff, + randomFactor, + maxRestarts + ) { () => + subscribe(topic, config) + .via(processMessage) + .log(topic.name) + .mapConcat(identity) + } + .to(acknowledge) + .run() + } + + def handleMessage[A]( + topic: Topic[A], + config: SubscriptionConfig = defaultSubscriptionConfig, + parallelism: Int = 1, + minBackoff: FiniteDuration = 3.seconds, + maxBackoff: FiniteDuration = 30.seconds, + randomFactor: Double = 0.2, + maxRestarts: Int = 20 + )(processMessage: A => Future[_])(implicit mat: Materializer): NotUsed = { + runWithRestart(topic, config, minBackoff, maxBackoff, randomFactor, maxRestarts) { + Flow[Message[A]].mapAsync(parallelism) { message => + processMessage(message.data).map(_ => message.id :: Nil) + } + } + } +} diff --git a/core-messaging/src/main/scala/xyz/driver/core/messaging/Topic.scala b/core-messaging/src/main/scala/xyz/driver/core/messaging/Topic.scala new file mode 100644 index 0000000..32fd764 --- /dev/null +++ b/core-messaging/src/main/scala/xyz/driver/core/messaging/Topic.scala @@ -0,0 +1,43 @@ +package xyz.driver.core +package messaging + +import java.nio.ByteBuffer + +/** A topic is a named group of messages that all share a common schema. + * @tparam Message type of messages sent over this topic */ +trait Topic[Message] { + + /** Name of this topic (must be unique). */ + def name: String + + /** Convert a message to its wire format that will be sent over a bus. */ + def serialize(message: Message): ByteBuffer + + /** Convert a message from its wire format. */ + def deserialize(message: ByteBuffer): Message + +} + +object Topic { + + /** Create a new "raw" topic without a schema, providing access to the underlying bytes of messages. */ + def raw(name0: String): Topic[ByteBuffer] = new Topic[ByteBuffer] { + def name = name0 + override def serialize(message: ByteBuffer): ByteBuffer = message + override def deserialize(message: ByteBuffer): ByteBuffer = message + } + + /** Create a topic that represents data as UTF-8 encoded strings. */ + def string(name0: String): Topic[String] = new Topic[String] { + def name = name0 + override def serialize(message: String): ByteBuffer = { + ByteBuffer.wrap(message.getBytes("utf-8")) + } + override def deserialize(message: ByteBuffer): String = { + val bytes = new Array[Byte](message.remaining()) + message.get(bytes) + new String(bytes, "utf-8") + } + } + +} diff --git a/core-messaging/src/test/scala/xyz/driver/core/messaging/QueueBusTest.scala b/core-messaging/src/test/scala/xyz/driver/core/messaging/QueueBusTest.scala new file mode 100644 index 0000000..8dd0776 --- /dev/null +++ b/core-messaging/src/test/scala/xyz/driver/core/messaging/QueueBusTest.scala @@ -0,0 +1,30 @@ +package xyz.driver.core.messaging + +import akka.actor.ActorSystem +import org.scalatest.FlatSpec +import org.scalatest.concurrent.ScalaFutures + +import scala.concurrent.ExecutionContext +import scala.concurrent.duration._ + +class QueueBusTest extends FlatSpec with ScalaFutures { + implicit val patience: PatienceConfig = PatienceConfig(timeout = 10.seconds) + + def busBehaviour(bus: Bus)(implicit ec: ExecutionContext): Unit = { + + it should "deliver messages to a subscriber" in { + val topic = Topic.string("test.topic1") + bus.fetchMessages(topic).futureValue + bus.publishMessages(topic, Seq("hello world!")) + Thread.sleep(100) + val messages = bus.fetchMessages(topic) + assert(messages.futureValue.map(_.data).toList == List("hello world!")) + } + } + + implicit val system: ActorSystem = ActorSystem("queue-test") + import system.dispatcher + + "A queue-based bus" should behave like busBehaviour(new QueueBus) + +} diff --git a/core-storage/src/main/scala/xyz/driver/core/storage/AliyunBlobStorage.scala b/core-storage/src/main/scala/xyz/driver/core/storage/AliyunBlobStorage.scala new file mode 100644 index 0000000..7e59df4 --- /dev/null +++ b/core-storage/src/main/scala/xyz/driver/core/storage/AliyunBlobStorage.scala @@ -0,0 +1,108 @@ +package xyz.driver.core.storage + +import java.io.ByteArrayInputStream +import java.net.URL +import java.nio.file.Path +import java.time.Clock +import java.util.Date + +import akka.Done +import akka.stream.scaladsl.{Sink, Source, StreamConverters} +import akka.util.ByteString +import com.aliyun.oss.OSSClient +import com.aliyun.oss.model.ObjectPermission +import com.typesafe.config.Config + +import scala.collection.JavaConverters._ +import scala.concurrent.duration.Duration +import scala.concurrent.{ExecutionContext, Future} + +class AliyunBlobStorage( + client: OSSClient, + bucketId: String, + clock: Clock, + chunkSize: Int = AliyunBlobStorage.DefaultChunkSize)(implicit ec: ExecutionContext) + extends SignedBlobStorage { + override def uploadContent(name: String, content: Array[Byte]): Future[String] = Future { + client.putObject(bucketId, name, new ByteArrayInputStream(content)) + name + } + + override def uploadFile(name: String, content: Path): Future[String] = Future { + client.putObject(bucketId, name, content.toFile) + name + } + + override def exists(name: String): Future[Boolean] = Future { + client.doesObjectExist(bucketId, name) + } + + override def list(prefix: String): Future[Set[String]] = Future { + client.listObjects(bucketId, prefix).getObjectSummaries.asScala.map(_.getKey)(collection.breakOut) + } + + override def content(name: String): Future[Option[Array[Byte]]] = Future { + Option(client.getObject(bucketId, name)).map { obj => + val inputStream = obj.getObjectContent + Stream.continually(inputStream.read).takeWhile(_ != -1).map(_.toByte).toArray + } + } + + override def download(name: String): Future[Option[Source[ByteString, Any]]] = Future { + Option(client.getObject(bucketId, name)).map { obj => + StreamConverters.fromInputStream(() => obj.getObjectContent, chunkSize) + } + } + + override def upload(name: String): Future[Sink[ByteString, Future[Done]]] = Future { + StreamConverters + .asInputStream() + .mapMaterializedValue(is => + Future { + client.putObject(bucketId, name, is) + Done + }) + } + + override def delete(name: String): Future[String] = Future { + client.deleteObject(bucketId, name) + name + } + + override def url(name: String): Future[Option[URL]] = Future { + // Based on https://www.alibabacloud.com/help/faq-detail/39607.htm + Option(client.getObjectAcl(bucketId, name)).map { acl => + val isPrivate = acl.getPermission == ObjectPermission.Private + val bucket = client.getBucketInfo(bucketId).getBucket + val endpointUrl = if (isPrivate) bucket.getIntranetEndpoint else bucket.getExtranetEndpoint + new URL(s"https://$bucketId.$endpointUrl/$name") + } + } + + override def signedDownloadUrl(name: String, duration: Duration): Future[Option[URL]] = Future { + if (client.doesObjectExist(bucketId, name)) { + val expiration = new Date(clock.millis() + duration.toMillis) + Some(client.generatePresignedUrl(bucketId, name, expiration)) + } else { + None + } + } +} + +object AliyunBlobStorage { + val DefaultChunkSize: Int = 8192 + + def apply(config: Config, bucketId: String, clock: Clock)( + implicit ec: ExecutionContext): AliyunBlobStorage = { + val clientId = config.getString("storage.aliyun.clientId") + val clientSecret = config.getString("storage.aliyun.clientSecret") + val endpoint = config.getString("storage.aliyun.endpoint") + this(clientId, clientSecret, endpoint, bucketId, clock) + } + + def apply(clientId: String, clientSecret: String, endpoint: String, bucketId: String, clock: Clock)( + implicit ec: ExecutionContext): AliyunBlobStorage = { + val client = new OSSClient(endpoint, clientId, clientSecret) + new AliyunBlobStorage(client, bucketId, clock) + } +} diff --git a/core-storage/src/main/scala/xyz/driver/core/storage/BlobStorage.scala b/core-storage/src/main/scala/xyz/driver/core/storage/BlobStorage.scala new file mode 100644 index 0000000..0cde96a --- /dev/null +++ b/core-storage/src/main/scala/xyz/driver/core/storage/BlobStorage.scala @@ -0,0 +1,50 @@ +package xyz.driver.core.storage + +import java.net.URL +import java.nio.file.Path + +import akka.Done +import akka.stream.scaladsl.{Sink, Source} +import akka.util.ByteString + +import scala.concurrent.Future +import scala.concurrent.duration.Duration + +/** Binary key-value store, typically implemented by cloud storage. */ +trait BlobStorage { + + /** Upload data by value. */ + def uploadContent(name: String, content: Array[Byte]): Future[String] + + /** Upload data from an existing file. */ + def uploadFile(name: String, content: Path): Future[String] + + def exists(name: String): Future[Boolean] + + /** List available keys. The prefix determines which keys should be listed + * and depends on the implementation (for instance, a file system backed + * blob store will treat a prefix as a directory path). */ + def list(prefix: String): Future[Set[String]] + + /** Get all the content of a given object. */ + def content(name: String): Future[Option[Array[Byte]]] + + /** Stream data asynchronously and with backpressure. */ + def download(name: String): Future[Option[Source[ByteString, Any]]] + + /** Get a sink to upload data. */ + def upload(name: String): Future[Sink[ByteString, Future[Done]]] + + /** Delete a stored value. */ + def delete(name: String): Future[String] + + /** + * Path to specified resource. Checks that the resource exists and returns None if + * it is not found. Depending on the implementation, may throw. + */ + def url(name: String): Future[Option[URL]] +} + +trait SignedBlobStorage extends BlobStorage { + def signedDownloadUrl(name: String, duration: Duration): Future[Option[URL]] +} diff --git a/core-storage/src/main/scala/xyz/driver/core/storage/FileSystemBlobStorage.scala b/core-storage/src/main/scala/xyz/driver/core/storage/FileSystemBlobStorage.scala new file mode 100644 index 0000000..e12c73d --- /dev/null +++ b/core-storage/src/main/scala/xyz/driver/core/storage/FileSystemBlobStorage.scala @@ -0,0 +1,82 @@ +package xyz.driver.core.storage + +import java.net.URL +import java.nio.file.{Files, Path, StandardCopyOption} + +import akka.stream.scaladsl.{FileIO, Sink, Source} +import akka.util.ByteString +import akka.{Done, NotUsed} + +import scala.collection.JavaConverters._ +import scala.concurrent.{ExecutionContext, Future} + +/** A blob store that is backed by a local filesystem. All objects are stored relative to the given + * root path. Slashes ('/') in blob names are treated as usual path separators and are converted + * to directories. */ +class FileSystemBlobStorage(root: Path)(implicit ec: ExecutionContext) extends BlobStorage { + + private def ensureParents(file: Path): Path = { + Files.createDirectories(file.getParent()) + file + } + + private def file(name: String) = root.resolve(name) + + override def uploadContent(name: String, content: Array[Byte]): Future[String] = Future { + Files.write(ensureParents(file(name)), content) + name + } + override def uploadFile(name: String, content: Path): Future[String] = Future { + Files.copy(content, ensureParents(file(name)), StandardCopyOption.REPLACE_EXISTING) + name + } + + override def exists(name: String): Future[Boolean] = Future { + val path = file(name) + Files.exists(path) && Files.isReadable(path) + } + + override def list(prefix: String): Future[Set[String]] = Future { + val dir = file(prefix) + Files + .list(dir) + .iterator() + .asScala + .map(p => root.relativize(p)) + .map(_.toString) + .toSet + } + + override def content(name: String): Future[Option[Array[Byte]]] = exists(name) map { + case true => + Some(Files.readAllBytes(file(name))) + case false => None + } + + override def download(name: String): Future[Option[Source[ByteString, NotUsed]]] = Future { + if (Files.exists(file(name))) { + Some(FileIO.fromPath(file(name)).mapMaterializedValue(_ => NotUsed)) + } else { + None + } + } + + override def upload(name: String): Future[Sink[ByteString, Future[Done]]] = Future { + val f = ensureParents(file(name)) + FileIO.toPath(f).mapMaterializedValue(_.map(_ => Done)) + } + + override def delete(name: String): Future[String] = exists(name).map { e => + if (e) { + Files.delete(file(name)) + } + name + } + + override def url(name: String): Future[Option[URL]] = exists(name) map { + case true => + Some(root.resolve(name).toUri.toURL) + case false => + None + } +} diff --git a/core-storage/src/main/scala/xyz/driver/core/storage/GcsBlobStorage.scala b/core-storage/src/main/scala/xyz/driver/core/storage/GcsBlobStorage.scala new file mode 100644 index 0000000..95164c7 --- /dev/null +++ b/core-storage/src/main/scala/xyz/driver/core/storage/GcsBlobStorage.scala @@ -0,0 +1,96 @@ +package xyz.driver.core.storage + +import java.io.{FileInputStream, InputStream} +import java.net.URL +import java.nio.file.Path + +import akka.Done +import akka.stream.scaladsl.Sink +import akka.util.ByteString +import com.google.api.gax.paging.Page +import com.google.auth.oauth2.ServiceAccountCredentials +import com.google.cloud.storage.Storage.BlobListOption +import com.google.cloud.storage.{Blob, BlobId, Bucket, Storage, StorageOptions} + +import scala.collection.JavaConverters._ +import scala.concurrent.duration.Duration +import scala.concurrent.{ExecutionContext, Future} + +class GcsBlobStorage(client: Storage, bucketId: String, chunkSize: Int = GcsBlobStorage.DefaultChunkSize)( + implicit ec: ExecutionContext) + extends BlobStorage with SignedBlobStorage { + + private val bucket: Bucket = client.get(bucketId) + require(bucket != null, s"Bucket $bucketId does not exist.") + + override def uploadContent(name: String, content: Array[Byte]): Future[String] = Future { + bucket.create(name, content).getBlobId.getName + } + + override def uploadFile(name: String, content: Path): Future[String] = Future { + bucket.create(name, new FileInputStream(content.toFile)).getBlobId.getName + } + + override def exists(name: String): Future[Boolean] = Future { + bucket.get(name) != null + } + + override def list(prefix: String): Future[Set[String]] = Future { + val page: Page[Blob] = bucket.list(BlobListOption.prefix(prefix)) + page + .iterateAll() + .asScala + .map(_.getName()) + .toSet + } + + override def content(name: String): Future[Option[Array[Byte]]] = Future { + Option(bucket.get(name)).map(blob => blob.getContent()) + } + + override def download(name: String) = Future { + Option(bucket.get(name)).map { blob => + ChannelStream.fromChannel(() => blob.reader(), chunkSize) + } + } + + override def upload(name: String): Future[Sink[ByteString, Future[Done]]] = Future { + val blob = bucket.create(name, Array.emptyByteArray) + ChannelStream.toChannel(() => blob.writer(), chunkSize) + } + + override def delete(name: String): Future[String] = Future { + client.delete(BlobId.of(bucketId, name)) + name + } + + override def signedDownloadUrl(name: String, duration: Duration): Future[Option[URL]] = Future { + Option(bucket.get(name)).map(blob => blob.signUrl(duration.length, duration.unit)) + } + + override def url(name: String): Future[Option[URL]] = Future { + val protocol: String = "https" + val resourcePath: String = s"storage.googleapis.com/${bucket.getName}/" + Option(bucket.get(name)).map { blob => + new URL(protocol, resourcePath, blob.getName) + } + } +} + +object GcsBlobStorage { + final val DefaultChunkSize = 8192 + + private def newClient(key: InputStream): Storage = + StorageOptions + .newBuilder() + .setCredentials(ServiceAccountCredentials.fromStream(key)) + .build() + .getService() + + def fromKeyfile(keyfile: Path, bucketId: String, chunkSize: Int = DefaultChunkSize)( + implicit ec: ExecutionContext): GcsBlobStorage = { + val client = newClient(new FileInputStream(keyfile.toFile)) + new GcsBlobStorage(client, bucketId, chunkSize) + } + +} diff --git a/core-storage/src/main/scala/xyz/driver/core/storage/channelStreams.scala b/core-storage/src/main/scala/xyz/driver/core/storage/channelStreams.scala new file mode 100644 index 0000000..fc652be --- /dev/null +++ b/core-storage/src/main/scala/xyz/driver/core/storage/channelStreams.scala @@ -0,0 +1,112 @@ +package xyz.driver.core.storage + +import java.nio.ByteBuffer +import java.nio.channels.{ReadableByteChannel, WritableByteChannel} + +import akka.stream._ +import akka.stream.scaladsl.{Sink, Source} +import akka.stream.stage._ +import akka.util.ByteString +import akka.{Done, NotUsed} + +import scala.concurrent.{Future, Promise} +import scala.util.control.NonFatal + +class ChannelSource(createChannel: () => ReadableByteChannel, chunkSize: Int) + extends GraphStage[SourceShape[ByteString]] { + + val out = Outlet[ByteString]("ChannelSource.out") + val shape = SourceShape(out) + + override def createLogic(inheritedAttributes: Attributes): GraphStageLogic = new GraphStageLogic(shape) { + val channel = createChannel() + + object Handler extends OutHandler { + override def onPull(): Unit = { + try { + val buffer = ByteBuffer.allocate(chunkSize) + if (channel.read(buffer) > 0) { + buffer.flip() + push(out, ByteString.fromByteBuffer(buffer)) + } else { + completeStage() + } + } catch { + case NonFatal(_) => + channel.close() + } + } + override def onDownstreamFinish(): Unit = { + channel.close() + } + } + + setHandler(out, Handler) + } + +} + +class ChannelSink(createChannel: () => WritableByteChannel, chunkSize: Int) + extends GraphStageWithMaterializedValue[SinkShape[ByteString], Future[Done]] { + + val in = Inlet[ByteString]("ChannelSink.in") + val shape = SinkShape(in) + + override def createLogicAndMaterializedValue(inheritedAttributes: Attributes): (GraphStageLogic, Future[Done]) = { + val promise = Promise[Done]() + val logic = new GraphStageLogic(shape) { + val channel = createChannel() + + object Handler extends InHandler { + override def onPush(): Unit = { + try { + val data = grab(in) + channel.write(data.asByteBuffer) + pull(in) + } catch { + case NonFatal(e) => + channel.close() + promise.failure(e) + } + } + + override def onUpstreamFinish(): Unit = { + channel.close() + completeStage() + promise.success(Done) + } + + override def onUpstreamFailure(ex: Throwable): Unit = { + channel.close() + promise.failure(ex) + } + } + + setHandler(in, Handler) + + override def preStart(): Unit = { + pull(in) + } + } + (logic, promise.future) + } + +} + +object ChannelStream { + + def fromChannel(channel: () => ReadableByteChannel, chunkSize: Int = 8192): Source[ByteString, NotUsed] = { + Source + .fromGraph(new ChannelSource(channel, chunkSize)) + .withAttributes(Attributes(ActorAttributes.IODispatcher)) + .async + } + + def toChannel(channel: () => WritableByteChannel, chunkSize: Int = 8192): Sink[ByteString, Future[Done]] = { + Sink + .fromGraph(new ChannelSink(channel, chunkSize)) + .withAttributes(Attributes(ActorAttributes.IODispatcher)) + .async + } + +} diff --git a/core-storage/src/test/scala/xyz/driver/core/BlobStorageTest.scala b/core-storage/src/test/scala/xyz/driver/core/BlobStorageTest.scala new file mode 100644 index 0000000..811cc60 --- /dev/null +++ b/core-storage/src/test/scala/xyz/driver/core/BlobStorageTest.scala @@ -0,0 +1,94 @@ +package xyz.driver.core + +import java.nio.file.Files + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import akka.stream.scaladsl._ +import akka.util.ByteString +import org.scalatest._ +import org.scalatest.concurrent.ScalaFutures +import xyz.driver.core.storage.{BlobStorage, FileSystemBlobStorage} + +import scala.concurrent.Future +import scala.concurrent.duration._ +import scala.language.postfixOps + +class BlobStorageTest extends FlatSpec with ScalaFutures { + + implicit val patientce = PatienceConfig(timeout = 100.seconds) + + implicit val system = ActorSystem("blobstorage-test") + implicit val mat = ActorMaterializer() + import system.dispatcher + + def storageBehaviour(storage: BlobStorage) = { + val key = "foo" + val data = "hello world".getBytes + it should "upload data" in { + assert(storage.exists(key).futureValue === false) + assert(storage.uploadContent(key, data).futureValue === key) + assert(storage.exists(key).futureValue === true) + } + it should "download data" in { + val content = storage.content(key).futureValue + assert(content.isDefined) + assert(content.get === data) + } + it should "not download non-existing data" in { + assert(storage.content("bar").futureValue.isEmpty) + } + it should "overwrite an existing key" in { + val newData = "new string".getBytes("utf-8") + assert(storage.uploadContent(key, newData).futureValue === key) + assert(storage.content(key).futureValue.get === newData) + } + it should "upload a file" in { + val tmp = Files.createTempFile("testfile", "txt") + Files.write(tmp, data) + assert(storage.uploadFile(key, tmp).futureValue === key) + Files.delete(tmp) + } + it should "upload content" in { + val text = "foobar" + val src = Source + .single(text) + .map(l => ByteString(l)) + src.runWith(storage.upload(key).futureValue).futureValue + assert(storage.content(key).futureValue.map(_.toSeq) === Some("foobar".getBytes.toSeq)) + } + it should "delete content" in { + assert(storage.exists(key).futureValue) + storage.delete(key).futureValue + assert(!storage.exists(key).futureValue) + } + it should "download content" in { + storage.uploadContent(key, data) futureValue + val srcOpt = storage.download(key).futureValue + assert(srcOpt.isDefined) + val src = srcOpt.get + val content: Future[Array[Byte]] = src.runWith(Sink.fold(Array[Byte]())(_ ++ _)) + assert(content.futureValue === data) + } + it should "list keys" in { + assert(storage.list("").futureValue === Set(key)) + storage.uploadContent("a/a.txt", data).futureValue + storage.uploadContent("a/b", data).futureValue + storage.uploadContent("c/d", data).futureValue + storage.uploadContent("d", data).futureValue + assert(storage.list("").futureValue === Set(key, "a", "c", "d")) + assert(storage.list("a").futureValue === Set("a/a.txt", "a/b")) + assert(storage.list("a").futureValue === Set("a/a.txt", "a/b")) + assert(storage.list("c").futureValue === Set("c/d")) + } + it should "get valid URL" in { + assert(storage.exists(key).futureValue === true) + val fooUrl = storage.url(key).futureValue + assert(fooUrl.isDefined) + } + } + + "File system storage" should behave like storageBehaviour( + new FileSystemBlobStorage(Files.createTempDirectory("test"))) + +} diff --git a/src/main/scala/xyz/driver/core/messaging/AliyunBus.scala b/src/main/scala/xyz/driver/core/messaging/AliyunBus.scala deleted file mode 100644 index 8b7bca7..0000000 --- a/src/main/scala/xyz/driver/core/messaging/AliyunBus.scala +++ /dev/null @@ -1,157 +0,0 @@ -package xyz.driver.core.messaging -import java.nio.ByteBuffer -import java.util - -import com.aliyun.mns.client.{AsyncCallback, CloudAccount} -import com.aliyun.mns.common.ServiceException -import com.aliyun.mns.model -import com.aliyun.mns.model._ - -import scala.concurrent.duration._ -import scala.concurrent.{ExecutionContext, Future, Promise} - -class AliyunBus( - accountId: String, - accessId: String, - accessSecret: String, - region: String, - namespace: String, - pullTimeout: Int -)(implicit val executionContext: ExecutionContext) - extends Bus { - private val endpoint = s"https://$accountId.mns.$region.aliyuncs.com" - private val cloudAccount = new CloudAccount(accessId, accessSecret, endpoint) - private val client = cloudAccount.getMNSClient - - // When calling the asyncBatchPopMessage endpoint, alicloud returns an error if no message is received before the - // pullTimeout. This error is documented as MessageNotExist, however it's been observed to return InternalServerError - // occasionally. We mask both of these errors and return an empty list of messages - private val MaskedErrorCodes: Set[String] = Set("MessageNotExist", "InternalServerError") - - override val defaultMaxMessages: Int = 10 - - case class MessageId(queueName: String, messageHandle: String) - - case class Message[A](id: MessageId, data: A) extends BasicMessage[A] - - case class SubscriptionConfig( - subscriptionPrefix: String = accessId, - ackTimeout: FiniteDuration = 10.seconds - ) - - override val defaultSubscriptionConfig: SubscriptionConfig = SubscriptionConfig() - - private def rawTopicName(topic: Topic[_]) = - s"$namespace-${topic.name}" - private def rawSubscriptionName(config: SubscriptionConfig, topic: Topic[_]) = - s"$namespace-${config.subscriptionPrefix}-${topic.name}" - - override def fetchMessages[A]( - topic: Topic[A], - config: SubscriptionConfig, - maxMessages: Int): Future[Seq[Message[A]]] = { - import collection.JavaConverters._ - val subscriptionName = rawSubscriptionName(config, topic) - val queueRef = client.getQueueRef(subscriptionName) - - val promise = Promise[Seq[model.Message]] - queueRef.asyncBatchPopMessage( - maxMessages, - pullTimeout, - new AsyncCallback[util.List[model.Message]] { - override def onSuccess(result: util.List[model.Message]): Unit = { - promise.success(result.asScala) - } - override def onFail(ex: Exception): Unit = ex match { - case serviceException: ServiceException if MaskedErrorCodes(serviceException.getErrorCode) => - promise.success(Nil) - case _ => - promise.failure(ex) - } - } - ) - - promise.future.map(_.map { message => - import scala.xml.XML - val messageId = MessageId(subscriptionName, message.getReceiptHandle) - val messageXML = XML.loadString(message.getMessageBodyAsRawString) - val messageNode = messageXML \ "Message" - val messageBytes = java.util.Base64.getDecoder.decode(messageNode.head.text) - - val deserializedMessage = topic.deserialize(ByteBuffer.wrap(messageBytes)) - Message(messageId, deserializedMessage) - }) - } - - override def acknowledgeMessages(messages: Seq[MessageId]): Future[Unit] = { - import collection.JavaConverters._ - require(messages.nonEmpty, "Acknowledged message list must be non-empty") - - val queueRef = client.getQueueRef(messages.head.queueName) - - val promise = Promise[Unit] - queueRef.asyncBatchDeleteMessage( - messages.map(_.messageHandle).asJava, - new AsyncCallback[Void] { - override def onSuccess(result: Void): Unit = promise.success(()) - override def onFail(ex: Exception): Unit = promise.failure(ex) - } - ) - - promise.future - } - - override def publishMessages[A](topic: Topic[A], messages: Seq[A]): Future[Unit] = { - val topicRef = client.getTopicRef(rawTopicName(topic)) - - val publishMessages = messages.map { message => - val promise = Promise[TopicMessage] - - val topicMessage = new Base64TopicMessage - topicMessage.setMessageBody(topic.serialize(message).array()) - - topicRef.asyncPublishMessage( - topicMessage, - new AsyncCallback[TopicMessage] { - override def onSuccess(result: TopicMessage): Unit = promise.success(result) - override def onFail(ex: Exception): Unit = promise.failure(ex) - } - ) - - promise.future - } - - Future.sequence(publishMessages).map(_ => ()) - } - - def createTopic(topic: Topic[_]): Future[Unit] = Future { - val topicName = rawTopicName(topic) - val topicExists = Option(client.listTopic(topicName, "", 1)).exists(!_.getResult.isEmpty) - if (!topicExists) { - val topicMeta = new TopicMeta - topicMeta.setTopicName(topicName) - client.createTopic(topicMeta) - } - } - - def createSubscription(topic: Topic[_], config: SubscriptionConfig): Future[Unit] = Future { - val subscriptionName = rawSubscriptionName(config, topic) - val queueExists = Option(client.listQueue(subscriptionName, "", 1)).exists(!_.getResult.isEmpty) - - if (!queueExists) { - val topicName = rawTopicName(topic) - val topicRef = client.getTopicRef(topicName) - - val queueMeta = new QueueMeta - queueMeta.setQueueName(subscriptionName) - queueMeta.setVisibilityTimeout(config.ackTimeout.toSeconds) - client.createQueue(queueMeta) - - val subscriptionMeta = new SubscriptionMeta - subscriptionMeta.setSubscriptionName(subscriptionName) - subscriptionMeta.setTopicName(topicName) - subscriptionMeta.setEndpoint(topicRef.generateQueueEndpoint(subscriptionName)) - topicRef.subscribe(subscriptionMeta) - } - } -} diff --git a/src/main/scala/xyz/driver/core/messaging/Bus.scala b/src/main/scala/xyz/driver/core/messaging/Bus.scala deleted file mode 100644 index 75954f4..0000000 --- a/src/main/scala/xyz/driver/core/messaging/Bus.scala +++ /dev/null @@ -1,74 +0,0 @@ -package xyz.driver.core -package messaging - -import scala.concurrent._ -import scala.language.higherKinds - -/** Base trait for representing message buses. - * - * Message buses are expected to provide "at least once" delivery semantics and are - * expected to retry delivery when a message remains unacknowledged for a reasonable - * amount of time. */ -trait Bus { - - /** Type of unique message identifiers. Usually a string or UUID. */ - type MessageId - - /** Most general kind of message. Any implementation of a message bus must provide - * the fields and methods specified in this trait. */ - trait BasicMessage[A] { self: Message[A] => - - /** All messages must have unique IDs so that they can be acknowledged unambiguously. */ - def id: MessageId - - /** Actual message content. */ - def data: A - - } - - /** Actual type of messages provided by this bus. This must be a subtype of BasicMessage - * (as that defines the minimal required fields of a messages), but may be refined to - * provide bus-specific additional data. */ - type Message[A] <: BasicMessage[A] - - /** Type of a bus-specific configuration object can be used to tweak settings of subscriptions. */ - type SubscriptionConfig - - /** Default value for a subscription configuration. It is such that any service will have a unique subscription - * for every topic, shared among all its instances. */ - val defaultSubscriptionConfig: SubscriptionConfig - - /** Maximum amount of messages handled in a single retrieval call. */ - val defaultMaxMessages = 64 - - /** Execution context that is used to query and dispatch messages from this bus. */ - implicit val executionContext: ExecutionContext - - /** Retrieve any new messages in the mailbox of a subscription. - * - * Any retrieved messages become "outstanding" and should not be returned by this function - * again until a reasonable (bus-specific) amount of time has passed and they remain unacknowledged. - * In that case, they will again be considered new and will be returned by this function. - * - * Note that although outstanding and acknowledged messages will eventually be removed from - * mailboxes, no guarantee can be made that a message will be delivered only once. */ - def fetchMessages[A]( - topic: Topic[A], - config: SubscriptionConfig = defaultSubscriptionConfig, - maxMessages: Int = defaultMaxMessages): Future[Seq[Message[A]]] - - /** Acknowledge that a given series of messages has been handled and should - * not be delivered again. - * - * Note that messages become eventually acknowledged and hence may be delivered more than once. - * @see fetchMessages() - */ - def acknowledgeMessages(messages: Seq[MessageId]): Future[Unit] - - /** Send a series of messages to a topic. - * - * The returned future will complete once messages have been accepted to the underlying bus. - * No guarantee can be made of their delivery to subscribers. */ - def publishMessages[A](topic: Topic[A], messages: Seq[A]): Future[Unit] - -} diff --git a/src/main/scala/xyz/driver/core/messaging/CreateOnDemand.scala b/src/main/scala/xyz/driver/core/messaging/CreateOnDemand.scala deleted file mode 100644 index 1af5308..0000000 --- a/src/main/scala/xyz/driver/core/messaging/CreateOnDemand.scala +++ /dev/null @@ -1,49 +0,0 @@ -package xyz.driver.core -package messaging - -import java.util.concurrent.ConcurrentHashMap - -import scala.async.Async.{async, await} -import scala.concurrent.Future - -/** Utility mixin that will ensure that topics and subscriptions exist before - * attempting to read or write from or to them. - */ -trait CreateOnDemand extends Bus { - - /** Create the given topic. This operation is idempotent and is expected to succeed if the topic - * already exists. - */ - def createTopic(topic: Topic[_]): Future[Unit] - - /** Create the given subscription. This operation is idempotent and is expected to succeed if the subscription - * already exists. - */ - def createSubscription(topic: Topic[_], config: SubscriptionConfig): Future[Unit] - - private val createdTopics = new ConcurrentHashMap[Topic[_], Future[Unit]] - private val createdSubscriptions = new ConcurrentHashMap[(Topic[_], SubscriptionConfig), Future[Unit]] - - private def ensureTopic(topic: Topic[_]) = - createdTopics.computeIfAbsent(topic, t => createTopic(t)) - - private def ensureSubscription(topic: Topic[_], config: SubscriptionConfig) = - createdSubscriptions.computeIfAbsent(topic -> config, { - case (t, c) => createSubscription(t, c) - }) - - abstract override def publishMessages[A](topic: Topic[A], messages: Seq[A]): Future[Unit] = async { - await(ensureTopic(topic)) - await(super.publishMessages(topic, messages)) - } - - abstract override def fetchMessages[A]( - topic: Topic[A], - config: SubscriptionConfig, - maxMessages: Int): Future[Seq[Message[A]]] = async { - await(ensureTopic(topic)) - await(ensureSubscription(topic, config)) - await(super.fetchMessages(topic, config, maxMessages)) - } - -} diff --git a/src/main/scala/xyz/driver/core/messaging/GoogleBus.scala b/src/main/scala/xyz/driver/core/messaging/GoogleBus.scala deleted file mode 100644 index b296c50..0000000 --- a/src/main/scala/xyz/driver/core/messaging/GoogleBus.scala +++ /dev/null @@ -1,267 +0,0 @@ -package xyz.driver.core -package messaging - -import java.nio.ByteBuffer -import java.nio.file.{Files, Path, Paths} -import java.security.Signature -import java.time.Instant -import java.util - -import com.google.auth.oauth2.ServiceAccountCredentials -import com.softwaremill.sttp._ -import spray.json.DefaultJsonProtocol._ -import spray.json._ - -import scala.async.Async.{async, await} -import scala.concurrent._ -import scala.concurrent.duration._ - -/** A message bus implemented by [[https://cloud.google.com/pubsub/docs/overview Google's Pub/Sub service.]] - * - * == Overview == - * - * The Pub/Sub message system is focused around a few concepts: 'topics', - * 'subscriptions' and 'subscribers'. Messages are sent to ''topics'' which may - * have multiple ''subscriptions'' associated to them. Every subscription to a - * topic will receive all messages sent to the topic. Messages are enqueued in - * a subscription until they are acknowledged by a ''subscriber''. Multiple - * subscribers may be associated to a subscription, in which case messages will - * get delivered arbitrarily among them. - * - * Topics and subscriptions are named resources which can be specified in - * Pub/Sub's configuration and may be queried. Subscribers on the other hand, - * are ephemeral processes that query a subscription on a regular basis, handle any - * messages and acknowledge them. - * - * == Delivery semantics == - * - * - at least once - * - no ordering - * - * == Retention == - * - * - configurable retry delay for unacknowledged messages, defaults to 10s - * - undeliverable messages are kept for 7 days - * - * @param credentials Google cloud credentials, usually the same as used by a - * service. Must have admin access to topics and - * descriptions. - * @param namespace The namespace in which this bus is running. Will be used to - * determine the exact name of topics and subscriptions. - * @param pullTimeout Delay after which a call to fetchMessages() will return an - * empty list, assuming that no messages have been received. - * @param executionContext Execution context to run any blocking commands. - * @param backend sttp backend used to query Pub/Sub's HTTP API - */ -class GoogleBus( - credentials: ServiceAccountCredentials, - namespace: String, - pullTimeout: Duration = 90.seconds -)(implicit val executionContext: ExecutionContext, backend: SttpBackend[Future, _]) - extends Bus { - import GoogleBus.Protocol - - case class MessageId(subscription: String, ackId: String) - - case class PubsubMessage[A](id: MessageId, data: A, publishTime: Instant) extends super.BasicMessage[A] - type Message[A] = PubsubMessage[A] - - /** Subscription-specific configuration - * - * @param subscriptionPrefix An identifier used to uniquely determine the name of Pub/Sub subscriptions. - * All messages sent to a subscription will be dispatched arbitrarily - * among any subscribers. Defaults to the email of the credentials used by this - * bus instance, thereby giving every service a unique subscription to every topic. - * To give every service instance a unique subscription, this must be changed to a - * unique value. - * @param ackTimeout Duration in which a message must be acknowledged before it is delivered again. - */ - case class SubscriptionConfig( - subscriptionPrefix: String = credentials.getClientEmail.split("@")(0), - ackTimeout: FiniteDuration = 10.seconds - ) - override val defaultSubscriptionConfig: SubscriptionConfig = SubscriptionConfig() - - /** Obtain an authentication token valid for the given duration - * https://developers.google.com/identity/protocols/OAuth2ServiceAccount - */ - private def freshAuthToken(duration: FiniteDuration): Future[String] = { - def jwt = { - val now = Instant.now().getEpochSecond - val base64 = util.Base64.getEncoder - val header = base64.encodeToString("""{"alg":"RS256","typ":"JWT"}""".getBytes("utf-8")) - val body = base64.encodeToString( - s"""|{ - | "iss": "${credentials.getClientEmail}", - | "scope": "https://www.googleapis.com/auth/pubsub", - | "aud": "https://www.googleapis.com/oauth2/v4/token", - | "exp": ${now + duration.toSeconds}, - | "iat": $now - |}""".stripMargin.getBytes("utf-8") - ) - val signer = Signature.getInstance("SHA256withRSA") - signer.initSign(credentials.getPrivateKey) - signer.update(s"$header.$body".getBytes("utf-8")) - val signature = base64.encodeToString(signer.sign()) - s"$header.$body.$signature" - } - sttp - .post(uri"https://www.googleapis.com/oauth2/v4/token") - .body( - "grant_type" -> "urn:ietf:params:oauth:grant-type:jwt-bearer", - "assertion" -> jwt - ) - .mapResponse(s => s.parseJson.asJsObject.fields("access_token").convertTo[String]) - .send() - .map(_.unsafeBody) - } - - // the token is cached a few minutes less than its validity to diminish latency of concurrent accesses at renewal time - private val getToken: () => Future[String] = Refresh.every(55.minutes)(freshAuthToken(60.minutes)) - - private val baseUri = uri"https://pubsub.googleapis.com/" - - private def rawTopicName(topic: Topic[_]) = - s"projects/${credentials.getProjectId}/topics/$namespace.${topic.name}" - private def rawSubscriptionName(config: SubscriptionConfig, topic: Topic[_]) = - s"projects/${credentials.getProjectId}/subscriptions/$namespace.${config.subscriptionPrefix}.${topic.name}" - - def createTopic(topic: Topic[_]): Future[Unit] = async { - val request = sttp - .put(baseUri.path(s"v1/${rawTopicName(topic)}")) - .auth - .bearer(await(getToken())) - val result = await(request.send()) - result.body match { - case Left(error) if result.code != 409 => // 409 <=> topic already exists, ignore it - throw new NoSuchElementException(s"Error creating topic: Status code ${result.code}: $error") - case _ => () - } - } - - def createSubscription(topic: Topic[_], config: SubscriptionConfig): Future[Unit] = async { - val request = sttp - .put(baseUri.path(s"v1/${rawSubscriptionName(config, topic)}")) - .auth - .bearer(await(getToken())) - .body( - JsObject( - "topic" -> rawTopicName(topic).toJson, - "ackDeadlineSeconds" -> config.ackTimeout.toSeconds.toJson - ).compactPrint - ) - val result = await(request.send()) - result.body match { - case Left(error) if result.code != 409 => // 409 <=> subscription already exists, ignore it - throw new NoSuchElementException(s"Error creating subscription: Status code ${result.code}: $error") - case _ => () - } - } - - override def publishMessages[A](topic: Topic[A], messages: Seq[A]): Future[Unit] = async { - import Protocol.bufferFormat - val buffers: Seq[ByteBuffer] = messages.map(topic.serialize) - val request = sttp - .post(baseUri.path(s"v1/${rawTopicName(topic)}:publish")) - .auth - .bearer(await(getToken())) - .body( - JsObject("messages" -> buffers.map(buffer => JsObject("data" -> buffer.toJson)).toJson).compactPrint - ) - await(request.send()).unsafeBody - () - } - - override def fetchMessages[A]( - topic: Topic[A], - subscriptionConfig: SubscriptionConfig, - maxMessages: Int): Future[Seq[PubsubMessage[A]]] = async { - val subscription = rawSubscriptionName(subscriptionConfig, topic) - val request = sttp - .post(baseUri.path(s"v1/$subscription:pull")) - .auth - .bearer(await(getToken().map(x => x))) - .body( - JsObject( - "returnImmediately" -> JsFalse, - "maxMessages" -> JsNumber(maxMessages) - ).compactPrint - ) - .readTimeout(pullTimeout) - .mapResponse(_.parseJson) - - val messages = await(request.send()).unsafeBody match { - case JsObject(fields) if fields.isEmpty => Seq() - case obj => obj.convertTo[Protocol.SubscriptionPull].receivedMessages - } - - messages.map { msg => - PubsubMessage[A]( - MessageId(subscription, msg.ackId), - topic.deserialize(msg.message.data), - msg.message.publishTime - ) - } - } - - override def acknowledgeMessages(messageIds: Seq[MessageId]): Future[Unit] = async { - val request = sttp - .post(baseUri.path(s"v1/${messageIds.head.subscription}:acknowledge")) - .auth - .bearer(await(getToken())) - .body( - JsObject("ackIds" -> JsArray(messageIds.toVector.map(m => JsString(m.ackId)))).compactPrint - ) - await(request.send()).unsafeBody - () - } - -} - -object GoogleBus { - - private object Protocol extends DefaultJsonProtocol { - case class SubscriptionPull(receivedMessages: Seq[ReceivedMessage]) - case class ReceivedMessage(ackId: String, message: PubsubMessage) - case class PubsubMessage(data: ByteBuffer, publishTime: Instant) - - implicit val timeFormat: JsonFormat[Instant] = new JsonFormat[Instant] { - override def write(obj: Instant): JsValue = JsString(obj.toString) - override def read(json: JsValue): Instant = Instant.parse(json.convertTo[String]) - } - implicit val bufferFormat: JsonFormat[ByteBuffer] = new JsonFormat[ByteBuffer] { - override def write(obj: ByteBuffer): JsValue = - JsString(util.Base64.getEncoder.encodeToString(obj.array())) - - override def read(json: JsValue): ByteBuffer = { - val encodedBytes = json.convertTo[String].getBytes("utf-8") - val decodedBytes = util.Base64.getDecoder.decode(encodedBytes) - ByteBuffer.wrap(decodedBytes) - } - } - - implicit val pubsubMessageFormat: RootJsonFormat[PubsubMessage] = jsonFormat2(PubsubMessage) - implicit val receivedMessageFormat: RootJsonFormat[ReceivedMessage] = jsonFormat2(ReceivedMessage) - implicit val subscrptionPullFormat: RootJsonFormat[SubscriptionPull] = jsonFormat1(SubscriptionPull) - } - - def fromKeyfile(keyfile: Path, namespace: String)( - implicit executionContext: ExecutionContext, - backend: SttpBackend[Future, _]): GoogleBus = { - val creds = ServiceAccountCredentials.fromStream(Files.newInputStream(keyfile)) - new GoogleBus(creds, namespace) - } - - @deprecated( - "Reading from the environment adds opaque dependencies and hance leads to extra complexity. Use fromKeyfile instead.", - "driver-core 1.12.2") - def fromEnv(implicit executionContext: ExecutionContext, backend: SttpBackend[Future, _]): GoogleBus = { - def env(key: String) = { - require(sys.env.contains(key), s"Environment variable $key is not set.") - sys.env(key) - } - val keyfile = Paths.get(env("GOOGLE_APPLICATION_CREDENTIALS")) - fromKeyfile(keyfile, env("SERVICE_NAMESPACE")) - } - -} diff --git a/src/main/scala/xyz/driver/core/messaging/QueueBus.scala b/src/main/scala/xyz/driver/core/messaging/QueueBus.scala deleted file mode 100644 index 45c9ed5..0000000 --- a/src/main/scala/xyz/driver/core/messaging/QueueBus.scala +++ /dev/null @@ -1,126 +0,0 @@ -package xyz.driver.core -package messaging - -import java.nio.ByteBuffer - -import akka.actor.{Actor, ActorSystem, Props} - -import scala.collection.mutable -import scala.collection.mutable.Map -import scala.concurrent.duration._ -import scala.concurrent.{Future, Promise} - -/** A bus backed by an asynchronous queue. Note that this bus requires a local actor system - * and is intended for testing purposes only. */ -class QueueBus(implicit system: ActorSystem) extends Bus { - import system.dispatcher - - override type SubscriptionConfig = Long - override val defaultSubscriptionConfig: Long = 0 - override val executionContext = system.dispatcher - - override type MessageId = (String, SubscriptionConfig, Long) - type Message[A] = BasicMessage[A] - - private object ActorMessages { - case class Send[A](topic: Topic[A], messages: Seq[A]) - case class Ack(messages: Seq[MessageId]) - case class Fetch[A](topic: Topic[A], cfg: SubscriptionConfig, max: Int, response: Promise[Seq[Message[A]]]) - case object Unack - } - - class Subscription { - val mailbox: mutable.Map[MessageId, ByteBuffer] = mutable.Map.empty - val unacked: mutable.Map[MessageId, ByteBuffer] = mutable.Map.empty - } - - private class BusMaster extends Actor { - var _id = 0L - def nextId(topic: String, cfg: SubscriptionConfig): (String, SubscriptionConfig, Long) = { - _id += 1; (topic, cfg, _id) - } - - val topics: mutable.Map[String, mutable.Map[SubscriptionConfig, Subscription]] = mutable.Map.empty - - def ensureSubscription(topic: String, cfg: SubscriptionConfig): Unit = { - topics.get(topic) match { - case Some(t) => - t.getOrElseUpdate(cfg, new Subscription) - case None => - topics += topic -> mutable.Map.empty - ensureSubscription(topic, cfg) - } - } - - override def preStart(): Unit = { - context.system.scheduler.schedule(1.seconds, 1.seconds) { - self ! ActorMessages.Unack - } - } - - override def receive: Receive = { - case ActorMessages.Send(topic, messages) => - val buffers = messages.map(topic.serialize) - val subscriptions = topics.getOrElse(topic.name, Map.empty) - for ((cfg, subscription) <- subscriptions) { - for (buffer <- buffers) { - subscription.mailbox += nextId(topic.name, cfg) -> buffer - } - } - - case ActorMessages.Fetch(topic, cfg, max, promise) => - ensureSubscription(topic.name, cfg) - val subscription = topics(topic.name)(cfg) - val messages = subscription.mailbox.take(max) - subscription.unacked ++= messages - subscription.mailbox --= messages.map(_._1) - promise.success(messages.toSeq.map { - case (ackId, buffer) => - new Message[Any] { - val id = ackId - val data = topic.deserialize(buffer) - } - }) - - case ActorMessages.Ack(messageIds) => - for (id @ (topic, cfg, _) <- messageIds) { - ensureSubscription(topic, cfg) - val subscription = topics(topic)(cfg) - subscription.unacked -= id - } - - case ActorMessages.Unack => - for ((_, subscriptions) <- topics) { - for ((_, subscription) <- subscriptions) { - subscription.mailbox ++= subscription.unacked - subscription.unacked.clear() - } - } - } - - } - - val actor = system.actorOf(Props(new BusMaster)) - - override def publishMessages[A](topic: Topic[A], messages: Seq[A]): Future[Unit] = Future { - actor ! ActorMessages.Send(topic, messages) - } - - override def fetchMessages[A]( - topic: messaging.Topic[A], - config: SubscriptionConfig, - maxMessages: Int): Future[Seq[Message[A]]] = { - val result = Promise[Seq[Message[A]]] - actor ! ActorMessages.Fetch(topic, config, maxMessages, result) - result.future - } - - override def acknowledgeMessages(ids: Seq[MessageId]): Future[Unit] = Future { - actor ! ActorMessages.Ack(ids) - } - -} - -object QueueBus { - def apply(implicit system: ActorSystem) = new QueueBus -} diff --git a/src/main/scala/xyz/driver/core/messaging/StreamBus.scala b/src/main/scala/xyz/driver/core/messaging/StreamBus.scala deleted file mode 100644 index 44d75cd..0000000 --- a/src/main/scala/xyz/driver/core/messaging/StreamBus.scala +++ /dev/null @@ -1,102 +0,0 @@ -package xyz.driver.core -package messaging - -import akka.NotUsed -import akka.stream.Materializer -import akka.stream.scaladsl.{Flow, RestartSource, Sink, Source} - -import scala.collection.mutable.ListBuffer -import scala.concurrent.Future -import scala.concurrent.duration._ - -/** An extension to message buses that offers an Akka-Streams API. - * - * Example usage of a stream that subscribes to one topic, prints any messages - * it receives and finally acknowledges them - * their receipt. - * {{{ - * val bus: StreamBus = ??? - * val topic = Topic.string("topic") - * bus.subscribe(topic1) - * .map{ msg => - * print(msg.data) - * msg - * } - * .to(bus.acknowledge) - * .run() - * }}} */ -trait StreamBus extends Bus { - - /** Flow that publishes any messages to a given topic. - * Emits messages once they have been published to the underlying bus. */ - def publish[A](topic: Topic[A]): Flow[A, A, NotUsed] = { - Flow[A] - .batch(defaultMaxMessages.toLong, a => ListBuffer[A](a))(_ += _) - .mapAsync(1) { a => - publishMessages(topic, a).map(_ => a) - } - .mapConcat(list => list.toList) - } - - /** Sink that acknowledges the receipt of a message. */ - def acknowledge: Sink[MessageId, NotUsed] = { - Flow[MessageId] - .batch(defaultMaxMessages.toLong, a => ListBuffer[MessageId](a))(_ += _) - .mapAsync(1)(acknowledgeMessages(_)) - .to(Sink.ignore) - } - - /** Source that listens to a subscription and receives any messages sent to its topic. */ - def subscribe[A]( - topic: Topic[A], - config: SubscriptionConfig = defaultSubscriptionConfig): Source[Message[A], NotUsed] = { - Source - .unfoldAsync((topic, config))( - topicAndConfig => - fetchMessages(topicAndConfig._1, topicAndConfig._2, defaultMaxMessages).map(msgs => - Some(topicAndConfig -> msgs)) - ) - .filter(_.nonEmpty) - .mapConcat(messages => messages.toList) - } - - def runWithRestart[A]( - topic: Topic[A], - config: SubscriptionConfig = defaultSubscriptionConfig, - minBackoff: FiniteDuration = 3.seconds, - maxBackoff: FiniteDuration = 30.seconds, - randomFactor: Double = 0.2, - maxRestarts: Int = 20 - )(processMessage: Flow[Message[A], List[MessageId], NotUsed])(implicit mat: Materializer): NotUsed = { - RestartSource - .withBackoff[MessageId]( - minBackoff, - maxBackoff, - randomFactor, - maxRestarts - ) { () => - subscribe(topic, config) - .via(processMessage) - .log(topic.name) - .mapConcat(identity) - } - .to(acknowledge) - .run() - } - - def handleMessage[A]( - topic: Topic[A], - config: SubscriptionConfig = defaultSubscriptionConfig, - parallelism: Int = 1, - minBackoff: FiniteDuration = 3.seconds, - maxBackoff: FiniteDuration = 30.seconds, - randomFactor: Double = 0.2, - maxRestarts: Int = 20 - )(processMessage: A => Future[_])(implicit mat: Materializer): NotUsed = { - runWithRestart(topic, config, minBackoff, maxBackoff, randomFactor, maxRestarts) { - Flow[Message[A]].mapAsync(parallelism) { message => - processMessage(message.data).map(_ => message.id :: Nil) - } - } - } -} diff --git a/src/main/scala/xyz/driver/core/messaging/Topic.scala b/src/main/scala/xyz/driver/core/messaging/Topic.scala deleted file mode 100644 index 32fd764..0000000 --- a/src/main/scala/xyz/driver/core/messaging/Topic.scala +++ /dev/null @@ -1,43 +0,0 @@ -package xyz.driver.core -package messaging - -import java.nio.ByteBuffer - -/** A topic is a named group of messages that all share a common schema. - * @tparam Message type of messages sent over this topic */ -trait Topic[Message] { - - /** Name of this topic (must be unique). */ - def name: String - - /** Convert a message to its wire format that will be sent over a bus. */ - def serialize(message: Message): ByteBuffer - - /** Convert a message from its wire format. */ - def deserialize(message: ByteBuffer): Message - -} - -object Topic { - - /** Create a new "raw" topic without a schema, providing access to the underlying bytes of messages. */ - def raw(name0: String): Topic[ByteBuffer] = new Topic[ByteBuffer] { - def name = name0 - override def serialize(message: ByteBuffer): ByteBuffer = message - override def deserialize(message: ByteBuffer): ByteBuffer = message - } - - /** Create a topic that represents data as UTF-8 encoded strings. */ - def string(name0: String): Topic[String] = new Topic[String] { - def name = name0 - override def serialize(message: String): ByteBuffer = { - ByteBuffer.wrap(message.getBytes("utf-8")) - } - override def deserialize(message: ByteBuffer): String = { - val bytes = new Array[Byte](message.remaining()) - message.get(bytes) - new String(bytes, "utf-8") - } - } - -} diff --git a/src/main/scala/xyz/driver/core/storage/AliyunBlobStorage.scala b/src/main/scala/xyz/driver/core/storage/AliyunBlobStorage.scala deleted file mode 100644 index b5e8678..0000000 --- a/src/main/scala/xyz/driver/core/storage/AliyunBlobStorage.scala +++ /dev/null @@ -1,108 +0,0 @@ -package xyz.driver.core.storage - -import java.io.ByteArrayInputStream -import java.net.URL -import java.nio.file.Path -import java.util.Date - -import akka.Done -import akka.stream.scaladsl.{Sink, Source, StreamConverters} -import akka.util.ByteString -import com.aliyun.oss.OSSClient -import com.aliyun.oss.model.ObjectPermission -import com.typesafe.config.Config -import xyz.driver.core.time.provider.TimeProvider - -import scala.collection.JavaConverters._ -import scala.concurrent.duration.Duration -import scala.concurrent.{ExecutionContext, Future} - -class AliyunBlobStorage( - client: OSSClient, - bucketId: String, - timeProvider: TimeProvider, - chunkSize: Int = AliyunBlobStorage.DefaultChunkSize)(implicit ec: ExecutionContext) - extends SignedBlobStorage { - override def uploadContent(name: String, content: Array[Byte]): Future[String] = Future { - client.putObject(bucketId, name, new ByteArrayInputStream(content)) - name - } - - override def uploadFile(name: String, content: Path): Future[String] = Future { - client.putObject(bucketId, name, content.toFile) - name - } - - override def exists(name: String): Future[Boolean] = Future { - client.doesObjectExist(bucketId, name) - } - - override def list(prefix: String): Future[Set[String]] = Future { - client.listObjects(bucketId, prefix).getObjectSummaries.asScala.map(_.getKey)(collection.breakOut) - } - - override def content(name: String): Future[Option[Array[Byte]]] = Future { - Option(client.getObject(bucketId, name)).map { obj => - val inputStream = obj.getObjectContent - Stream.continually(inputStream.read).takeWhile(_ != -1).map(_.toByte).toArray - } - } - - override def download(name: String): Future[Option[Source[ByteString, Any]]] = Future { - Option(client.getObject(bucketId, name)).map { obj => - StreamConverters.fromInputStream(() => obj.getObjectContent, chunkSize) - } - } - - override def upload(name: String): Future[Sink[ByteString, Future[Done]]] = Future { - StreamConverters - .asInputStream() - .mapMaterializedValue(is => - Future { - client.putObject(bucketId, name, is) - Done - }) - } - - override def delete(name: String): Future[String] = Future { - client.deleteObject(bucketId, name) - name - } - - override def url(name: String): Future[Option[URL]] = Future { - // Based on https://www.alibabacloud.com/help/faq-detail/39607.htm - Option(client.getObjectAcl(bucketId, name)).map { acl => - val isPrivate = acl.getPermission == ObjectPermission.Private - val bucket = client.getBucketInfo(bucketId).getBucket - val endpointUrl = if (isPrivate) bucket.getIntranetEndpoint else bucket.getExtranetEndpoint - new URL(s"https://$bucketId.$endpointUrl/$name") - } - } - - override def signedDownloadUrl(name: String, duration: Duration): Future[Option[URL]] = Future { - if (client.doesObjectExist(bucketId, name)) { - val expiration = new Date(timeProvider.currentTime().advanceBy(duration).millis) - Some(client.generatePresignedUrl(bucketId, name, expiration)) - } else { - None - } - } -} - -object AliyunBlobStorage { - val DefaultChunkSize: Int = 8192 - - def apply(config: Config, bucketId: String, timeProvider: TimeProvider)( - implicit ec: ExecutionContext): AliyunBlobStorage = { - val clientId = config.getString("storage.aliyun.clientId") - val clientSecret = config.getString("storage.aliyun.clientSecret") - val endpoint = config.getString("storage.aliyun.endpoint") - this(clientId, clientSecret, endpoint, bucketId, timeProvider) - } - - def apply(clientId: String, clientSecret: String, endpoint: String, bucketId: String, timeProvider: TimeProvider)( - implicit ec: ExecutionContext): AliyunBlobStorage = { - val client = new OSSClient(endpoint, clientId, clientSecret) - new AliyunBlobStorage(client, bucketId, timeProvider) - } -} diff --git a/src/main/scala/xyz/driver/core/storage/BlobStorage.scala b/src/main/scala/xyz/driver/core/storage/BlobStorage.scala deleted file mode 100644 index 0cde96a..0000000 --- a/src/main/scala/xyz/driver/core/storage/BlobStorage.scala +++ /dev/null @@ -1,50 +0,0 @@ -package xyz.driver.core.storage - -import java.net.URL -import java.nio.file.Path - -import akka.Done -import akka.stream.scaladsl.{Sink, Source} -import akka.util.ByteString - -import scala.concurrent.Future -import scala.concurrent.duration.Duration - -/** Binary key-value store, typically implemented by cloud storage. */ -trait BlobStorage { - - /** Upload data by value. */ - def uploadContent(name: String, content: Array[Byte]): Future[String] - - /** Upload data from an existing file. */ - def uploadFile(name: String, content: Path): Future[String] - - def exists(name: String): Future[Boolean] - - /** List available keys. The prefix determines which keys should be listed - * and depends on the implementation (for instance, a file system backed - * blob store will treat a prefix as a directory path). */ - def list(prefix: String): Future[Set[String]] - - /** Get all the content of a given object. */ - def content(name: String): Future[Option[Array[Byte]]] - - /** Stream data asynchronously and with backpressure. */ - def download(name: String): Future[Option[Source[ByteString, Any]]] - - /** Get a sink to upload data. */ - def upload(name: String): Future[Sink[ByteString, Future[Done]]] - - /** Delete a stored value. */ - def delete(name: String): Future[String] - - /** - * Path to specified resource. Checks that the resource exists and returns None if - * it is not found. Depending on the implementation, may throw. - */ - def url(name: String): Future[Option[URL]] -} - -trait SignedBlobStorage extends BlobStorage { - def signedDownloadUrl(name: String, duration: Duration): Future[Option[URL]] -} diff --git a/src/main/scala/xyz/driver/core/storage/FileSystemBlobStorage.scala b/src/main/scala/xyz/driver/core/storage/FileSystemBlobStorage.scala deleted file mode 100644 index e12c73d..0000000 --- a/src/main/scala/xyz/driver/core/storage/FileSystemBlobStorage.scala +++ /dev/null @@ -1,82 +0,0 @@ -package xyz.driver.core.storage - -import java.net.URL -import java.nio.file.{Files, Path, StandardCopyOption} - -import akka.stream.scaladsl.{FileIO, Sink, Source} -import akka.util.ByteString -import akka.{Done, NotUsed} - -import scala.collection.JavaConverters._ -import scala.concurrent.{ExecutionContext, Future} - -/** A blob store that is backed by a local filesystem. All objects are stored relative to the given - * root path. Slashes ('/') in blob names are treated as usual path separators and are converted - * to directories. */ -class FileSystemBlobStorage(root: Path)(implicit ec: ExecutionContext) extends BlobStorage { - - private def ensureParents(file: Path): Path = { - Files.createDirectories(file.getParent()) - file - } - - private def file(name: String) = root.resolve(name) - - override def uploadContent(name: String, content: Array[Byte]): Future[String] = Future { - Files.write(ensureParents(file(name)), content) - name - } - override def uploadFile(name: String, content: Path): Future[String] = Future { - Files.copy(content, ensureParents(file(name)), StandardCopyOption.REPLACE_EXISTING) - name - } - - override def exists(name: String): Future[Boolean] = Future { - val path = file(name) - Files.exists(path) && Files.isReadable(path) - } - - override def list(prefix: String): Future[Set[String]] = Future { - val dir = file(prefix) - Files - .list(dir) - .iterator() - .asScala - .map(p => root.relativize(p)) - .map(_.toString) - .toSet - } - - override def content(name: String): Future[Option[Array[Byte]]] = exists(name) map { - case true => - Some(Files.readAllBytes(file(name))) - case false => None - } - - override def download(name: String): Future[Option[Source[ByteString, NotUsed]]] = Future { - if (Files.exists(file(name))) { - Some(FileIO.fromPath(file(name)).mapMaterializedValue(_ => NotUsed)) - } else { - None - } - } - - override def upload(name: String): Future[Sink[ByteString, Future[Done]]] = Future { - val f = ensureParents(file(name)) - FileIO.toPath(f).mapMaterializedValue(_.map(_ => Done)) - } - - override def delete(name: String): Future[String] = exists(name).map { e => - if (e) { - Files.delete(file(name)) - } - name - } - - override def url(name: String): Future[Option[URL]] = exists(name) map { - case true => - Some(root.resolve(name).toUri.toURL) - case false => - None - } -} diff --git a/src/main/scala/xyz/driver/core/storage/GcsBlobStorage.scala b/src/main/scala/xyz/driver/core/storage/GcsBlobStorage.scala deleted file mode 100644 index 95164c7..0000000 --- a/src/main/scala/xyz/driver/core/storage/GcsBlobStorage.scala +++ /dev/null @@ -1,96 +0,0 @@ -package xyz.driver.core.storage - -import java.io.{FileInputStream, InputStream} -import java.net.URL -import java.nio.file.Path - -import akka.Done -import akka.stream.scaladsl.Sink -import akka.util.ByteString -import com.google.api.gax.paging.Page -import com.google.auth.oauth2.ServiceAccountCredentials -import com.google.cloud.storage.Storage.BlobListOption -import com.google.cloud.storage.{Blob, BlobId, Bucket, Storage, StorageOptions} - -import scala.collection.JavaConverters._ -import scala.concurrent.duration.Duration -import scala.concurrent.{ExecutionContext, Future} - -class GcsBlobStorage(client: Storage, bucketId: String, chunkSize: Int = GcsBlobStorage.DefaultChunkSize)( - implicit ec: ExecutionContext) - extends BlobStorage with SignedBlobStorage { - - private val bucket: Bucket = client.get(bucketId) - require(bucket != null, s"Bucket $bucketId does not exist.") - - override def uploadContent(name: String, content: Array[Byte]): Future[String] = Future { - bucket.create(name, content).getBlobId.getName - } - - override def uploadFile(name: String, content: Path): Future[String] = Future { - bucket.create(name, new FileInputStream(content.toFile)).getBlobId.getName - } - - override def exists(name: String): Future[Boolean] = Future { - bucket.get(name) != null - } - - override def list(prefix: String): Future[Set[String]] = Future { - val page: Page[Blob] = bucket.list(BlobListOption.prefix(prefix)) - page - .iterateAll() - .asScala - .map(_.getName()) - .toSet - } - - override def content(name: String): Future[Option[Array[Byte]]] = Future { - Option(bucket.get(name)).map(blob => blob.getContent()) - } - - override def download(name: String) = Future { - Option(bucket.get(name)).map { blob => - ChannelStream.fromChannel(() => blob.reader(), chunkSize) - } - } - - override def upload(name: String): Future[Sink[ByteString, Future[Done]]] = Future { - val blob = bucket.create(name, Array.emptyByteArray) - ChannelStream.toChannel(() => blob.writer(), chunkSize) - } - - override def delete(name: String): Future[String] = Future { - client.delete(BlobId.of(bucketId, name)) - name - } - - override def signedDownloadUrl(name: String, duration: Duration): Future[Option[URL]] = Future { - Option(bucket.get(name)).map(blob => blob.signUrl(duration.length, duration.unit)) - } - - override def url(name: String): Future[Option[URL]] = Future { - val protocol: String = "https" - val resourcePath: String = s"storage.googleapis.com/${bucket.getName}/" - Option(bucket.get(name)).map { blob => - new URL(protocol, resourcePath, blob.getName) - } - } -} - -object GcsBlobStorage { - final val DefaultChunkSize = 8192 - - private def newClient(key: InputStream): Storage = - StorageOptions - .newBuilder() - .setCredentials(ServiceAccountCredentials.fromStream(key)) - .build() - .getService() - - def fromKeyfile(keyfile: Path, bucketId: String, chunkSize: Int = DefaultChunkSize)( - implicit ec: ExecutionContext): GcsBlobStorage = { - val client = newClient(new FileInputStream(keyfile.toFile)) - new GcsBlobStorage(client, bucketId, chunkSize) - } - -} diff --git a/src/main/scala/xyz/driver/core/storage/channelStreams.scala b/src/main/scala/xyz/driver/core/storage/channelStreams.scala deleted file mode 100644 index fc652be..0000000 --- a/src/main/scala/xyz/driver/core/storage/channelStreams.scala +++ /dev/null @@ -1,112 +0,0 @@ -package xyz.driver.core.storage - -import java.nio.ByteBuffer -import java.nio.channels.{ReadableByteChannel, WritableByteChannel} - -import akka.stream._ -import akka.stream.scaladsl.{Sink, Source} -import akka.stream.stage._ -import akka.util.ByteString -import akka.{Done, NotUsed} - -import scala.concurrent.{Future, Promise} -import scala.util.control.NonFatal - -class ChannelSource(createChannel: () => ReadableByteChannel, chunkSize: Int) - extends GraphStage[SourceShape[ByteString]] { - - val out = Outlet[ByteString]("ChannelSource.out") - val shape = SourceShape(out) - - override def createLogic(inheritedAttributes: Attributes): GraphStageLogic = new GraphStageLogic(shape) { - val channel = createChannel() - - object Handler extends OutHandler { - override def onPull(): Unit = { - try { - val buffer = ByteBuffer.allocate(chunkSize) - if (channel.read(buffer) > 0) { - buffer.flip() - push(out, ByteString.fromByteBuffer(buffer)) - } else { - completeStage() - } - } catch { - case NonFatal(_) => - channel.close() - } - } - override def onDownstreamFinish(): Unit = { - channel.close() - } - } - - setHandler(out, Handler) - } - -} - -class ChannelSink(createChannel: () => WritableByteChannel, chunkSize: Int) - extends GraphStageWithMaterializedValue[SinkShape[ByteString], Future[Done]] { - - val in = Inlet[ByteString]("ChannelSink.in") - val shape = SinkShape(in) - - override def createLogicAndMaterializedValue(inheritedAttributes: Attributes): (GraphStageLogic, Future[Done]) = { - val promise = Promise[Done]() - val logic = new GraphStageLogic(shape) { - val channel = createChannel() - - object Handler extends InHandler { - override def onPush(): Unit = { - try { - val data = grab(in) - channel.write(data.asByteBuffer) - pull(in) - } catch { - case NonFatal(e) => - channel.close() - promise.failure(e) - } - } - - override def onUpstreamFinish(): Unit = { - channel.close() - completeStage() - promise.success(Done) - } - - override def onUpstreamFailure(ex: Throwable): Unit = { - channel.close() - promise.failure(ex) - } - } - - setHandler(in, Handler) - - override def preStart(): Unit = { - pull(in) - } - } - (logic, promise.future) - } - -} - -object ChannelStream { - - def fromChannel(channel: () => ReadableByteChannel, chunkSize: Int = 8192): Source[ByteString, NotUsed] = { - Source - .fromGraph(new ChannelSource(channel, chunkSize)) - .withAttributes(Attributes(ActorAttributes.IODispatcher)) - .async - } - - def toChannel(channel: () => WritableByteChannel, chunkSize: Int = 8192): Sink[ByteString, Future[Done]] = { - Sink - .fromGraph(new ChannelSink(channel, chunkSize)) - .withAttributes(Attributes(ActorAttributes.IODispatcher)) - .async - } - -} diff --git a/src/test/scala/xyz/driver/core/BlobStorageTest.scala b/src/test/scala/xyz/driver/core/BlobStorageTest.scala deleted file mode 100644 index 811cc60..0000000 --- a/src/test/scala/xyz/driver/core/BlobStorageTest.scala +++ /dev/null @@ -1,94 +0,0 @@ -package xyz.driver.core - -import java.nio.file.Files - -import akka.actor.ActorSystem -import akka.stream.ActorMaterializer -import akka.stream.scaladsl._ -import akka.util.ByteString -import org.scalatest._ -import org.scalatest.concurrent.ScalaFutures -import xyz.driver.core.storage.{BlobStorage, FileSystemBlobStorage} - -import scala.concurrent.Future -import scala.concurrent.duration._ -import scala.language.postfixOps - -class BlobStorageTest extends FlatSpec with ScalaFutures { - - implicit val patientce = PatienceConfig(timeout = 100.seconds) - - implicit val system = ActorSystem("blobstorage-test") - implicit val mat = ActorMaterializer() - import system.dispatcher - - def storageBehaviour(storage: BlobStorage) = { - val key = "foo" - val data = "hello world".getBytes - it should "upload data" in { - assert(storage.exists(key).futureValue === false) - assert(storage.uploadContent(key, data).futureValue === key) - assert(storage.exists(key).futureValue === true) - } - it should "download data" in { - val content = storage.content(key).futureValue - assert(content.isDefined) - assert(content.get === data) - } - it should "not download non-existing data" in { - assert(storage.content("bar").futureValue.isEmpty) - } - it should "overwrite an existing key" in { - val newData = "new string".getBytes("utf-8") - assert(storage.uploadContent(key, newData).futureValue === key) - assert(storage.content(key).futureValue.get === newData) - } - it should "upload a file" in { - val tmp = Files.createTempFile("testfile", "txt") - Files.write(tmp, data) - assert(storage.uploadFile(key, tmp).futureValue === key) - Files.delete(tmp) - } - it should "upload content" in { - val text = "foobar" - val src = Source - .single(text) - .map(l => ByteString(l)) - src.runWith(storage.upload(key).futureValue).futureValue - assert(storage.content(key).futureValue.map(_.toSeq) === Some("foobar".getBytes.toSeq)) - } - it should "delete content" in { - assert(storage.exists(key).futureValue) - storage.delete(key).futureValue - assert(!storage.exists(key).futureValue) - } - it should "download content" in { - storage.uploadContent(key, data) futureValue - val srcOpt = storage.download(key).futureValue - assert(srcOpt.isDefined) - val src = srcOpt.get - val content: Future[Array[Byte]] = src.runWith(Sink.fold(Array[Byte]())(_ ++ _)) - assert(content.futureValue === data) - } - it should "list keys" in { - assert(storage.list("").futureValue === Set(key)) - storage.uploadContent("a/a.txt", data).futureValue - storage.uploadContent("a/b", data).futureValue - storage.uploadContent("c/d", data).futureValue - storage.uploadContent("d", data).futureValue - assert(storage.list("").futureValue === Set(key, "a", "c", "d")) - assert(storage.list("a").futureValue === Set("a/a.txt", "a/b")) - assert(storage.list("a").futureValue === Set("a/a.txt", "a/b")) - assert(storage.list("c").futureValue === Set("c/d")) - } - it should "get valid URL" in { - assert(storage.exists(key).futureValue === true) - val fooUrl = storage.url(key).futureValue - assert(fooUrl.isDefined) - } - } - - "File system storage" should behave like storageBehaviour( - new FileSystemBlobStorage(Files.createTempDirectory("test"))) - -} diff --git a/src/test/scala/xyz/driver/core/messaging/QueueBusTest.scala b/src/test/scala/xyz/driver/core/messaging/QueueBusTest.scala deleted file mode 100644 index 8dd0776..0000000 --- a/src/test/scala/xyz/driver/core/messaging/QueueBusTest.scala +++ /dev/null @@ -1,30 +0,0 @@ -package xyz.driver.core.messaging - -import akka.actor.ActorSystem -import org.scalatest.FlatSpec -import org.scalatest.concurrent.ScalaFutures - -import scala.concurrent.ExecutionContext -import scala.concurrent.duration._ - -class QueueBusTest extends FlatSpec with ScalaFutures { - implicit val patience: PatienceConfig = PatienceConfig(timeout = 10.seconds) - - def busBehaviour(bus: Bus)(implicit ec: ExecutionContext): Unit = { - - it should "deliver messages to a subscriber" in { - val topic = Topic.string("test.topic1") - bus.fetchMessages(topic).futureValue - bus.publishMessages(topic, Seq("hello world!")) - Thread.sleep(100) - val messages = bus.fetchMessages(topic) - assert(messages.futureValue.map(_.data).toList == List("hello world!")) - } - } - - implicit val system: ActorSystem = ActorSystem("queue-test") - import system.dispatcher - - "A queue-based bus" should behave like busBehaviour(new QueueBus) - -} -- cgit v1.2.3 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 --- build.sbt | 2 +- .../src/main/scala/xyz/driver/core/auth.scala | 43 ++ .../main/scala/xyz/driver/core/generators.scala | 143 ++++++ .../src/main/scala/xyz/driver/core/json.scala | 398 ++++++++++++++++ .../scala/xyz/driver/core/rest/DnsDiscovery.scala | 11 + .../scala/xyz/driver/core/rest/DriverRoute.scala | 122 +++++ .../core/rest/HttpRestServiceTransport.scala | 103 ++++ .../xyz/driver/core/rest/PatchDirectives.scala | 104 ++++ .../xyz/driver/core/rest/PooledHttpClient.scala | 67 +++ .../scala/xyz/driver/core/rest/ProxyRoute.scala | 26 + .../scala/xyz/driver/core/rest/RestService.scala | 86 ++++ .../xyz/driver/core/rest/ServiceDescriptor.scala | 16 + .../driver/core/rest/SingleRequestHttpClient.scala | 29 ++ .../main/scala/xyz/driver/core/rest/Swagger.scala | 144 ++++++ .../core/rest/auth/AlwaysAllowAuthorization.scala | 14 + .../xyz/driver/core/rest/auth/AuthProvider.scala | 75 +++ .../xyz/driver/core/rest/auth/Authorization.scala | 11 + .../core/rest/auth/AuthorizationResult.scala | 22 + .../core/rest/auth/CachedTokenAuthorization.scala | 55 +++ .../core/rest/auth/ChainedAuthorization.scala | 27 ++ .../core/rest/directives/AuthDirectives.scala | 19 + .../core/rest/directives/CorsDirectives.scala | 72 +++ .../driver/core/rest/directives/Directives.scala | 6 + .../driver/core/rest/directives/PathMatchers.scala | 85 ++++ .../core/rest/directives/Unmarshallers.scala | 40 ++ .../driver/core/rest/errors/serviceException.scala | 27 ++ .../xyz/driver/core/rest/headers/Traceparent.scala | 33 ++ .../main/scala/xyz/driver/core/rest/package.scala | 323 +++++++++++++ .../xyz/driver/core/rest/serviceDiscovery.scala | 24 + .../driver/core/rest/serviceRequestContext.scala | 87 ++++ .../src/test/scala/xyz/driver/core/AuthTest.scala | 165 +++++++ .../scala/xyz/driver/core/GeneratorsTest.scala | 264 +++++++++++ .../src/test/scala/xyz/driver/core/JsonTest.scala | 521 +++++++++++++++++++++ .../src/test/scala/xyz/driver/core/TestTypes.scala | 14 + .../scala/xyz/driver/core/rest/DriverAppTest.scala | 89 ++++ .../xyz/driver/core/rest/DriverRouteTest.scala | 121 +++++ .../xyz/driver/core/rest/PatchDirectivesTest.scala | 101 ++++ .../test/scala/xyz/driver/core/rest/RestTest.scala | 151 ++++++ src/main/scala/xyz/driver/core/auth.scala | 43 -- src/main/scala/xyz/driver/core/generators.scala | 143 ------ src/main/scala/xyz/driver/core/json.scala | 398 ---------------- .../scala/xyz/driver/core/rest/DnsDiscovery.scala | 11 - .../scala/xyz/driver/core/rest/DriverRoute.scala | 122 ----- .../core/rest/HttpRestServiceTransport.scala | 103 ---- .../xyz/driver/core/rest/PatchDirectives.scala | 104 ---- .../xyz/driver/core/rest/PooledHttpClient.scala | 67 --- .../scala/xyz/driver/core/rest/ProxyRoute.scala | 26 - .../scala/xyz/driver/core/rest/RestService.scala | 86 ---- .../xyz/driver/core/rest/ServiceDescriptor.scala | 16 - .../driver/core/rest/SingleRequestHttpClient.scala | 29 -- src/main/scala/xyz/driver/core/rest/Swagger.scala | 144 ------ .../core/rest/auth/AlwaysAllowAuthorization.scala | 14 - .../xyz/driver/core/rest/auth/AuthProvider.scala | 75 --- .../xyz/driver/core/rest/auth/Authorization.scala | 11 - .../core/rest/auth/AuthorizationResult.scala | 22 - .../core/rest/auth/CachedTokenAuthorization.scala | 55 --- .../core/rest/auth/ChainedAuthorization.scala | 27 -- .../core/rest/directives/AuthDirectives.scala | 19 - .../core/rest/directives/CorsDirectives.scala | 72 --- .../driver/core/rest/directives/Directives.scala | 6 - .../driver/core/rest/directives/PathMatchers.scala | 85 ---- .../core/rest/directives/Unmarshallers.scala | 40 -- .../driver/core/rest/errors/serviceException.scala | 27 -- .../xyz/driver/core/rest/headers/Traceparent.scala | 33 -- src/main/scala/xyz/driver/core/rest/package.scala | 323 ------------- .../xyz/driver/core/rest/serviceDiscovery.scala | 24 - .../driver/core/rest/serviceRequestContext.scala | 87 ---- src/test/scala/xyz/driver/core/AuthTest.scala | 165 ------- .../scala/xyz/driver/core/GeneratorsTest.scala | 264 ----------- src/test/scala/xyz/driver/core/JsonTest.scala | 521 --------------------- src/test/scala/xyz/driver/core/TestTypes.scala | 14 - .../scala/xyz/driver/core/rest/DriverAppTest.scala | 89 ---- .../xyz/driver/core/rest/DriverRouteTest.scala | 121 ----- .../xyz/driver/core/rest/PatchDirectivesTest.scala | 101 ---- src/test/scala/xyz/driver/core/rest/RestTest.scala | 151 ------ 75 files changed, 3639 insertions(+), 3639 deletions(-) create mode 100644 core-rest/src/main/scala/xyz/driver/core/auth.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/generators.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/json.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/DnsDiscovery.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/DriverRoute.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/HttpRestServiceTransport.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/PatchDirectives.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/PooledHttpClient.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/ProxyRoute.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/RestService.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/ServiceDescriptor.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/SingleRequestHttpClient.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/Swagger.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/auth/AlwaysAllowAuthorization.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/auth/AuthProvider.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/auth/Authorization.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/auth/AuthorizationResult.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/auth/CachedTokenAuthorization.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/auth/ChainedAuthorization.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/directives/AuthDirectives.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/directives/CorsDirectives.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/directives/Directives.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/directives/PathMatchers.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/directives/Unmarshallers.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/headers/Traceparent.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/package.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/serviceDiscovery.scala create mode 100644 core-rest/src/main/scala/xyz/driver/core/rest/serviceRequestContext.scala create mode 100644 core-rest/src/test/scala/xyz/driver/core/AuthTest.scala create mode 100644 core-rest/src/test/scala/xyz/driver/core/GeneratorsTest.scala create mode 100644 core-rest/src/test/scala/xyz/driver/core/JsonTest.scala create mode 100644 core-rest/src/test/scala/xyz/driver/core/TestTypes.scala create mode 100644 core-rest/src/test/scala/xyz/driver/core/rest/DriverAppTest.scala create mode 100644 core-rest/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala create mode 100644 core-rest/src/test/scala/xyz/driver/core/rest/PatchDirectivesTest.scala create mode 100644 core-rest/src/test/scala/xyz/driver/core/rest/RestTest.scala delete mode 100644 src/main/scala/xyz/driver/core/auth.scala delete mode 100644 src/main/scala/xyz/driver/core/generators.scala delete mode 100644 src/main/scala/xyz/driver/core/json.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/DnsDiscovery.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/DriverRoute.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/HttpRestServiceTransport.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/PatchDirectives.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/PooledHttpClient.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/ProxyRoute.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/RestService.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/ServiceDescriptor.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/SingleRequestHttpClient.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/Swagger.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/auth/AlwaysAllowAuthorization.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/auth/AuthProvider.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/auth/Authorization.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/auth/AuthorizationResult.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/auth/CachedTokenAuthorization.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/auth/ChainedAuthorization.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/directives/AuthDirectives.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/directives/CorsDirectives.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/directives/Directives.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/directives/PathMatchers.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/directives/Unmarshallers.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/errors/serviceException.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/headers/Traceparent.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/package.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/serviceDiscovery.scala delete mode 100644 src/main/scala/xyz/driver/core/rest/serviceRequestContext.scala delete mode 100644 src/test/scala/xyz/driver/core/AuthTest.scala delete mode 100644 src/test/scala/xyz/driver/core/GeneratorsTest.scala delete mode 100644 src/test/scala/xyz/driver/core/JsonTest.scala delete mode 100644 src/test/scala/xyz/driver/core/TestTypes.scala delete mode 100644 src/test/scala/xyz/driver/core/rest/DriverAppTest.scala delete mode 100644 src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala delete mode 100644 src/test/scala/xyz/driver/core/rest/PatchDirectivesTest.scala delete mode 100644 src/test/scala/xyz/driver/core/rest/RestTest.scala diff --git a/build.sbt b/build.sbt index 403a35f..4f32af7 100644 --- a/build.sbt +++ b/build.sbt @@ -54,7 +54,7 @@ lazy val `core-types` = project lazy val `core-rest` = project .enablePlugins(LibraryPlugin) - .dependsOn(`core-util`, `core-types`) + .dependsOn(`core-util`, `core-types`, `core-reporting`) .settings(testdeps) lazy val `core-reporting` = project diff --git a/core-rest/src/main/scala/xyz/driver/core/auth.scala b/core-rest/src/main/scala/xyz/driver/core/auth.scala new file mode 100644 index 0000000..896bd89 --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/auth.scala @@ -0,0 +1,43 @@ +package xyz.driver.core + +import xyz.driver.core.domain.Email +import xyz.driver.core.time.Time +import scalaz.Equal + +object auth { + + trait Permission + + final case class Role(id: Id[Role], name: Name[Role]) { + + def oneOf(roles: Role*): Boolean = roles.contains(this) + + def oneOf(roles: Set[Role]): Boolean = roles.contains(this) + } + + object Role { + implicit def idEqual: Equal[Role] = Equal.equal[Role](_ == _) + } + + trait User { + def id: Id[User] + } + + final case class AuthToken(value: String) + + final case class AuthTokenUserInfo( + id: Id[User], + email: Email, + emailVerified: Boolean, + audience: String, + roles: Set[Role], + expirationTime: Time) + extends User + + final case class RefreshToken(value: String) + final case class PermissionsToken(value: String) + + final case class PasswordHash(value: String) + + final case class AuthCredentials(identifier: String, password: String) +} diff --git a/core-rest/src/main/scala/xyz/driver/core/generators.scala b/core-rest/src/main/scala/xyz/driver/core/generators.scala new file mode 100644 index 0000000..d00b6dd --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/generators.scala @@ -0,0 +1,143 @@ +package xyz.driver.core + +import enumeratum._ +import java.math.MathContext +import java.time.{Instant, LocalDate, ZoneOffset} +import java.util.UUID + +import xyz.driver.core.time.{Time, TimeOfDay, TimeRange} +import xyz.driver.core.date.{Date, DayOfWeek} + +import scala.reflect.ClassTag +import scala.util.Random +import eu.timepit.refined.refineV +import eu.timepit.refined.api.Refined +import eu.timepit.refined.collection._ + +object generators { + + private val random = new Random + import random._ + private val secureRandom = new java.security.SecureRandom() + + private val DefaultMaxLength = 10 + private val StringLetters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ ".toSet + private val NonAmbigiousCharacters = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + private val Numbers = "0123456789" + + private def nextTokenString(length: Int, chars: IndexedSeq[Char]): String = { + val builder = new StringBuilder + for (_ <- 0 until length) { + builder += chars(secureRandom.nextInt(chars.length)) + } + builder.result() + } + + /** Creates a random invitation token. + * + * This token is meant fo human input and avoids using ambiguous characters such as 'O' and '0'. It + * therefore contains less entropy and is not meant to be used as a cryptographic secret. */ + @deprecated( + "The term 'token' is too generic and security and readability conventions are not well defined. " + + "Services should implement their own version that suits their security requirements.", + "1.11.0" + ) + def nextToken(length: Int): String = nextTokenString(length, NonAmbigiousCharacters) + + @deprecated( + "The term 'token' is too generic and security and readability conventions are not well defined. " + + "Services should implement their own version that suits their security requirements.", + "1.11.0" + ) + def nextNumericToken(length: Int): String = nextTokenString(length, Numbers) + + def nextInt(maxValue: Int, minValue: Int = 0): Int = random.nextInt(maxValue - minValue) + minValue + + def nextBoolean(): Boolean = random.nextBoolean() + + def nextDouble(): Double = random.nextDouble() + + def nextId[T](): Id[T] = Id[T](nextUuid().toString) + + def nextId[T](maxLength: Int): Id[T] = Id[T](nextString(maxLength)) + + def nextNumericId[T](): Id[T] = Id[T](nextLong.abs.toString) + + def nextNumericId[T](maxValue: Int): Id[T] = Id[T](nextInt(maxValue).toString) + + def nextName[T](maxLength: Int = DefaultMaxLength): Name[T] = Name[T](nextString(maxLength)) + + def nextNonEmptyName[T](maxLength: Int = DefaultMaxLength): NonEmptyName[T] = + NonEmptyName[T](nextNonEmptyString(maxLength)) + + def nextUuid(): UUID = java.util.UUID.randomUUID + + def nextRevision[T](): Revision[T] = Revision[T](nextUuid().toString) + + def nextString(maxLength: Int = DefaultMaxLength): String = + (oneOf[Char](StringLetters) +: arrayOf(oneOf[Char](StringLetters), maxLength - 1)).mkString + + def nextNonEmptyString(maxLength: Int = DefaultMaxLength): String Refined NonEmpty = { + refineV[NonEmpty]( + (oneOf[Char](StringLetters) +: arrayOf(oneOf[Char](StringLetters), maxLength - 1)).mkString + ).right.get + } + + def nextOption[T](value: => T): Option[T] = if (nextBoolean()) Option(value) else None + + def nextPair[L, R](left: => L, right: => R): (L, R) = (left, right) + + def nextTriad[F, S, T](first: => F, second: => S, third: => T): (F, S, T) = (first, second, third) + + def nextInstant(): Instant = Instant.ofEpochMilli(math.abs(nextLong() % System.currentTimeMillis)) + + def nextTime(): Time = nextInstant() + + def nextTimeOfDay: TimeOfDay = TimeOfDay(java.time.LocalTime.MIN.plusSeconds(nextLong), java.util.TimeZone.getDefault) + + def nextTimeRange(): TimeRange = { + val oneTime = nextTime() + val anotherTime = nextTime() + + TimeRange( + Time(scala.math.min(oneTime.millis, anotherTime.millis)), + Time(scala.math.max(oneTime.millis, anotherTime.millis))) + } + + def nextDate(): Date = nextTime().toDate(java.util.TimeZone.getTimeZone("UTC")) + + def nextLocalDate(): LocalDate = nextInstant().atZone(ZoneOffset.UTC).toLocalDate + + def nextDayOfWeek(): DayOfWeek = oneOf(DayOfWeek.All) + + def nextBigDecimal(multiplier: Double = 1000000.00, precision: Int = 2): BigDecimal = + BigDecimal(multiplier * nextDouble, new MathContext(precision)) + + def oneOf[T](items: T*): T = oneOf(items.toSet) + + def oneOf[T](items: Set[T]): T = items.toSeq(nextInt(items.size)) + + def oneOf[T <: EnumEntry](enum: Enum[T]): T = oneOf(enum.values: _*) + + def arrayOf[T: ClassTag](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Array[T] = + Array.fill(nextInt(maxLength, minLength))(generator) + + def seqOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Seq[T] = + Seq.fill(nextInt(maxLength, minLength))(generator) + + def vectorOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Vector[T] = + Vector.fill(nextInt(maxLength, minLength))(generator) + + def listOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): List[T] = + List.fill(nextInt(maxLength, minLength))(generator) + + def setOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Set[T] = + seqOf(generator, maxLength, minLength).toSet + + def mapOf[K, V]( + keyGenerator: => K, + valueGenerator: => V, + maxLength: Int = DefaultMaxLength, + minLength: Int = 0): Map[K, V] = + seqOf(nextPair(keyGenerator, valueGenerator), maxLength, minLength).toMap +} 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") + } + +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/DnsDiscovery.scala b/core-rest/src/main/scala/xyz/driver/core/rest/DnsDiscovery.scala new file mode 100644 index 0000000..87946e4 --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/DnsDiscovery.scala @@ -0,0 +1,11 @@ +package xyz.driver.core +package rest + +class DnsDiscovery(transport: HttpRestServiceTransport, overrides: Map[String, String]) { + + def discover[A](implicit descriptor: ServiceDescriptor[A]): A = { + val url = overrides.getOrElse(descriptor.name, s"https://${descriptor.name}") + descriptor.connect(transport, url) + } + +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/DriverRoute.scala b/core-rest/src/main/scala/xyz/driver/core/rest/DriverRoute.scala new file mode 100644 index 0000000..911e306 --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/DriverRoute.scala @@ -0,0 +1,122 @@ +package xyz.driver.core.rest + +import java.sql.SQLException + +import akka.http.scaladsl.model.headers.CacheDirectives.`no-cache` +import akka.http.scaladsl.model.headers._ +import akka.http.scaladsl.model.{StatusCodes, _} +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server._ +import com.typesafe.scalalogging.Logger +import org.slf4j.MDC +import xyz.driver.core.rest +import xyz.driver.core.rest.errors._ + +import scala.compat.Platform.ConcurrentModificationException + +trait DriverRoute { + def log: Logger + + def route: Route + + def routeWithDefaults: Route = { + (defaultResponseHeaders & handleExceptions(ExceptionHandler(exceptionHandler))) { + route + } + } + + protected def defaultResponseHeaders: Directive0 = { + extractRequest flatMap { request => + // Needs to happen before any request processing, so all the log messages + // associated with processing of this request are having this `trackingId` + val trackingId = rest.extractTrackingId(request) + val tracingHeader = RawHeader(ContextHeaders.TrackingIdHeader, trackingId) + MDC.put("trackingId", trackingId) + + respondWithHeaders(tracingHeader +: DriverRoute.DefaultHeaders: _*) + } + } + + /** + * Override me for custom exception handling + * + * @return Exception handling route for exception type + */ + protected def exceptionHandler: PartialFunction[Throwable, Route] = { + case serviceException: ServiceException => + serviceExceptionHandler(serviceException) + + case is: IllegalStateException => + ctx => + log.warn(s"Request is not allowed to ${ctx.request.method} ${ctx.request.uri}", is) + errorResponse(StatusCodes.BadRequest, message = is.getMessage, is)(ctx) + + case cm: ConcurrentModificationException => + ctx => + log.warn(s"Concurrent modification of the resource ${ctx.request.method} ${ctx.request.uri}", cm) + errorResponse(StatusCodes.Conflict, "Resource was changed concurrently, try requesting a newer version", cm)( + ctx) + + case se: SQLException => + ctx => + log.warn(s"Database exception for the resource ${ctx.request.method} ${ctx.request.uri}", se) + errorResponse(StatusCodes.InternalServerError, "Data access error", se)(ctx) + + case t: Exception => + ctx => + log.warn(s"Request to ${ctx.request.method} ${ctx.request.uri} could not be handled normally", t) + errorResponse(StatusCodes.InternalServerError, t.getMessage, t)(ctx) + } + + protected def serviceExceptionHandler(serviceException: ServiceException): Route = { + val statusCode = serviceException match { + case e: InvalidInputException => + log.info("Invalid client input error", e) + StatusCodes.BadRequest + case e: InvalidActionException => + log.info("Invalid client action error", e) + StatusCodes.Forbidden + case e: UnauthorizedException => + log.info("Unauthorized user error", e) + StatusCodes.Unauthorized + case e: ResourceNotFoundException => + log.info("Resource not found error", e) + StatusCodes.NotFound + case e: ExternalServiceException => + log.error("Error while calling another service", e) + StatusCodes.InternalServerError + case e: ExternalServiceTimeoutException => + log.error("Service timeout error", e) + StatusCodes.GatewayTimeout + case e: DatabaseException => + log.error("Database error", e) + StatusCodes.InternalServerError + } + + { (ctx: RequestContext) => + import xyz.driver.core.json.serviceExceptionFormat + val entity = + HttpEntity(ContentTypes.`application/json`, serviceExceptionFormat.write(serviceException).toString()) + errorResponse(statusCode, entity, serviceException)(ctx) + } + } + + protected def errorResponse[T <: Exception](statusCode: StatusCode, message: String, exception: T): Route = + errorResponse(statusCode, HttpEntity(message), exception) + + protected def errorResponse[T <: Exception](statusCode: StatusCode, entity: ResponseEntity, exception: T): Route = { + complete(HttpResponse(statusCode, entity = entity)) + } + +} + +object DriverRoute { + val DefaultHeaders: List[HttpHeader] = List( + // This header will eliminate the risk of envoy trying to reuse a connection + // that already timed out on the server side by completely rejecting keep-alive + Connection("close"), + // These 2 headers are the simplest way to prevent IE from caching GET requests + RawHeader("Pragma", "no-cache"), + `Cache-Control`(List(`no-cache`(Nil))) + ) +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/HttpRestServiceTransport.scala b/core-rest/src/main/scala/xyz/driver/core/rest/HttpRestServiceTransport.scala new file mode 100644 index 0000000..e31635b --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/HttpRestServiceTransport.scala @@ -0,0 +1,103 @@ +package xyz.driver.core.rest + +import akka.actor.ActorSystem +import akka.http.scaladsl.model._ +import akka.http.scaladsl.model.headers.RawHeader +import akka.http.scaladsl.unmarshalling.Unmarshal +import akka.stream.Materializer +import akka.stream.scaladsl.TcpIdleTimeoutException +import org.slf4j.MDC +import xyz.driver.core.Name +import xyz.driver.core.reporting.Reporter +import xyz.driver.core.rest.errors.{ExternalServiceException, ExternalServiceTimeoutException} + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success} + +class HttpRestServiceTransport( + applicationName: Name[App], + applicationVersion: String, + val actorSystem: ActorSystem, + val executionContext: ExecutionContext, + reporter: Reporter) + extends ServiceTransport { + + protected implicit val execution: ExecutionContext = executionContext + + protected val httpClient: HttpClient = new SingleRequestHttpClient(applicationName, applicationVersion, actorSystem) + + def sendRequestGetResponse(context: ServiceRequestContext)(requestStub: HttpRequest): Future[HttpResponse] = { + val tags = Map( + // open tracing semantic tags + "span.kind" -> "client", + "service" -> applicationName.value, + "http.url" -> requestStub.uri.toString, + "http.method" -> requestStub.method.value, + "peer.hostname" -> requestStub.uri.authority.host.toString, + // google's tracing console provides extra search features if we define these tags + "/http/path" -> requestStub.uri.path.toString, + "/http/method" -> requestStub.method.value.toString, + "/http/url" -> requestStub.uri.toString + ) + reporter.traceAsync(s"http_call_rpc", tags) { implicit span => + val requestTime = System.currentTimeMillis() + + val request = requestStub + .withHeaders(context.contextHeaders.toSeq.map { + case (ContextHeaders.TrackingIdHeader, _) => + RawHeader(ContextHeaders.TrackingIdHeader, context.trackingId) + case (ContextHeaders.StacktraceHeader, _) => + RawHeader( + ContextHeaders.StacktraceHeader, + Option(MDC.get("stack")) + .orElse(context.contextHeaders.get(ContextHeaders.StacktraceHeader)) + .getOrElse("")) + case (header, headerValue) => RawHeader(header, headerValue) + }: _*) + + reporter.debug(s"Sending request to ${request.method} ${request.uri}") + + val response = httpClient.makeRequest(request) + + response.onComplete { + case Success(r) => + val responseLatency = System.currentTimeMillis() - requestTime + reporter.debug( + s"Response from ${request.uri} to request $requestStub is successful in $responseLatency ms: $r") + + case Failure(t: Throwable) => + val responseLatency = System.currentTimeMillis() - requestTime + reporter.warn( + s"Failed to receive response from ${request.method.value} ${request.uri} in $responseLatency ms", + t) + }(executionContext) + + response.recoverWith { + case _: TcpIdleTimeoutException => + val serviceCalled = s"${requestStub.method.value} ${requestStub.uri}" + Future.failed(ExternalServiceTimeoutException(serviceCalled)) + case t: Throwable => Future.failed(t) + } + }(context.spanContext) + } + + def sendRequest(context: ServiceRequestContext)(requestStub: HttpRequest)( + implicit mat: Materializer): Future[Unmarshal[ResponseEntity]] = { + + sendRequestGetResponse(context)(requestStub) flatMap { response => + if (response.status == StatusCodes.NotFound) { + Future.successful(Unmarshal(HttpEntity.Empty: ResponseEntity)) + } else if (response.status.isFailure()) { + val serviceCalled = s"${requestStub.method} ${requestStub.uri}" + Unmarshal(response.entity).to[String] flatMap { errorString => + import spray.json._ + import xyz.driver.core.json._ + val serviceException = util.Try(serviceExceptionFormat.read(errorString.parseJson)).toOption + Future.failed(ExternalServiceException(serviceCalled, errorString, serviceException)) + } + } else { + Future.successful(Unmarshal(response.entity)) + } + } + } +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/PatchDirectives.scala b/core-rest/src/main/scala/xyz/driver/core/rest/PatchDirectives.scala new file mode 100644 index 0000000..f33bf9d --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/PatchDirectives.scala @@ -0,0 +1,104 @@ +package xyz.driver.core.rest + +import akka.http.javadsl.server.Rejections +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport +import akka.http.scaladsl.model.{ContentTypeRange, HttpCharsets, MediaType} +import akka.http.scaladsl.server._ +import akka.http.scaladsl.unmarshalling.{FromEntityUnmarshaller, Unmarshaller} +import spray.json._ + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +trait PatchDirectives extends Directives with SprayJsonSupport { + + /** Media type for patches to JSON values, as specified in [[https://tools.ietf.org/html/rfc7396 RFC 7396]]. */ + val `application/merge-patch+json`: MediaType.WithFixedCharset = + MediaType.applicationWithFixedCharset("merge-patch+json", HttpCharsets.`UTF-8`) + + /** Wraps a JSON value that represents a patch. + * The patch must given in the format specified in [[https://tools.ietf.org/html/rfc7396 RFC 7396]]. */ + case class PatchValue(value: JsValue) { + + /** Applies this patch to a given original JSON value. In other words, merges the original with this "diff". */ + def applyTo(original: JsValue): JsValue = mergeJsValues(original, value) + } + + /** Witness that the given patch may be applied to an original domain value. + * @tparam A type of the domain value + * @param patch the patch that may be applied to a domain value + * @param format a JSON format that enables serialization and deserialization of a domain value */ + case class Patchable[A](patch: PatchValue, format: RootJsonFormat[A]) { + + /** Applies the patch to a given domain object. The result will be a combination + * of the original value, updates with the fields specified in this witness' patch. */ + def applyTo(original: A): A = { + val serialized = format.write(original) + val merged = patch.applyTo(serialized) + val deserialized = format.read(merged) + deserialized + } + } + + implicit def patchValueUnmarshaller: FromEntityUnmarshaller[PatchValue] = + Unmarshaller.byteStringUnmarshaller + .andThen(sprayJsValueByteStringUnmarshaller) + .forContentTypes(ContentTypeRange(`application/merge-patch+json`)) + .map(js => PatchValue(js)) + + implicit def patchableUnmarshaller[A]( + implicit patchUnmarshaller: FromEntityUnmarshaller[PatchValue], + format: RootJsonFormat[A]): FromEntityUnmarshaller[Patchable[A]] = { + patchUnmarshaller.map(patch => Patchable[A](patch, format)) + } + + protected def mergeObjects(oldObj: JsObject, newObj: JsObject, maxLevels: Option[Int] = None): JsObject = { + JsObject((oldObj.fields.keys ++ newObj.fields.keys).map({ key => + val oldValue = oldObj.fields.getOrElse(key, JsNull) + val newValue = newObj.fields.get(key).fold(oldValue)(mergeJsValues(oldValue, _, maxLevels.map(_ - 1))) + key -> newValue + })(collection.breakOut): _*) + } + + protected def mergeJsValues(oldValue: JsValue, newValue: JsValue, maxLevels: Option[Int] = None): JsValue = { + def mergeError(typ: String): Nothing = + deserializationError(s"Expected $typ value, got $newValue") + + if (maxLevels.exists(_ < 0)) oldValue + else { + (oldValue, newValue) match { + case (_: JsString, newString @ (JsString(_) | JsNull)) => newString + case (_: JsString, _) => mergeError("string") + case (_: JsNumber, newNumber @ (JsNumber(_) | JsNull)) => newNumber + case (_: JsNumber, _) => mergeError("number") + case (_: JsBoolean, newBool @ (JsBoolean(_) | JsNull)) => newBool + case (_: JsBoolean, _) => mergeError("boolean") + case (_: JsArray, newArr @ (JsArray(_) | JsNull)) => newArr + case (_: JsArray, _) => mergeError("array") + case (oldObj: JsObject, newObj: JsObject) => mergeObjects(oldObj, newObj) + case (_: JsObject, JsNull) => JsNull + case (_: JsObject, _) => mergeError("object") + case (JsNull, _) => newValue + } + } + } + + def mergePatch[T](patchable: Patchable[T], retrieve: => Future[Option[T]]): Directive1[T] = + Directive { inner => requestCtx => + onSuccess(retrieve)({ + case Some(oldT) => + Try(patchable.applyTo(oldT)) + .transform[Route]( + mergedT => scala.util.Success(inner(Tuple1(mergedT))), { + case jsonException: DeserializationException => + Success(reject(Rejections.malformedRequestContent(jsonException.getMessage, jsonException))) + case t => Failure(t) + } + ) + .get // intentionally re-throw all other errors + case None => reject() + })(requestCtx) + } +} + +object PatchDirectives extends PatchDirectives diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/PooledHttpClient.scala b/core-rest/src/main/scala/xyz/driver/core/rest/PooledHttpClient.scala new file mode 100644 index 0000000..2854257 --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/PooledHttpClient.scala @@ -0,0 +1,67 @@ +package xyz.driver.core.rest + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.http.scaladsl.model.headers.`User-Agent` +import akka.http.scaladsl.model.{HttpRequest, HttpResponse, Uri} +import akka.http.scaladsl.settings.{ClientConnectionSettings, ConnectionPoolSettings} +import akka.stream.scaladsl.{Keep, Sink, Source} +import akka.stream.{ActorMaterializer, OverflowStrategy, QueueOfferResult, ThrottleMode} +import xyz.driver.core.Name + +import scala.concurrent.{ExecutionContext, Future, Promise} +import scala.concurrent.duration._ +import scala.util.{Failure, Success} + +class PooledHttpClient( + baseUri: Uri, + applicationName: Name[App], + applicationVersion: String, + requestRateLimit: Int = 64, + requestQueueSize: Int = 1024)(implicit actorSystem: ActorSystem, executionContext: ExecutionContext) + extends HttpClient { + + private val host = baseUri.authority.host.toString() + private val port = baseUri.effectivePort + private val scheme = baseUri.scheme + + protected implicit val materializer: ActorMaterializer = ActorMaterializer()(actorSystem) + + private val clientConnectionSettings: ClientConnectionSettings = + ClientConnectionSettings(actorSystem).withUserAgentHeader( + Option(`User-Agent`(applicationName.value + "/" + applicationVersion))) + + private val connectionPoolSettings: ConnectionPoolSettings = ConnectionPoolSettings(actorSystem) + .withConnectionSettings(clientConnectionSettings) + + private val pool = if (scheme.equalsIgnoreCase("https")) { + Http().cachedHostConnectionPoolHttps[Promise[HttpResponse]](host, port, settings = connectionPoolSettings) + } else { + Http().cachedHostConnectionPool[Promise[HttpResponse]](host, port, settings = connectionPoolSettings) + } + + private val queue = Source + .queue[(HttpRequest, Promise[HttpResponse])](requestQueueSize, OverflowStrategy.dropNew) + .via(pool) + .throttle(requestRateLimit, 1.second, maximumBurst = requestRateLimit, ThrottleMode.shaping) + .toMat(Sink.foreach({ + case ((Success(resp), p)) => p.success(resp) + case ((Failure(e), p)) => p.failure(e) + }))(Keep.left) + .run + + def makeRequest(request: HttpRequest): Future[HttpResponse] = { + val responsePromise = Promise[HttpResponse]() + + queue.offer(request -> responsePromise).flatMap { + case QueueOfferResult.Enqueued => + responsePromise.future + case QueueOfferResult.Dropped => + Future.failed(new Exception(s"Request queue to the host $host is overflown")) + case QueueOfferResult.Failure(ex) => + Future.failed(ex) + case QueueOfferResult.QueueClosed => + Future.failed(new Exception("Queue was closed (pool shut down) while running the request")) + } + } +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/ProxyRoute.scala b/core-rest/src/main/scala/xyz/driver/core/rest/ProxyRoute.scala new file mode 100644 index 0000000..c0e9f99 --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/ProxyRoute.scala @@ -0,0 +1,26 @@ +package xyz.driver.core.rest + +import akka.http.scaladsl.server.{RequestContext, Route, RouteResult} +import com.typesafe.config.Config +import xyz.driver.core.Name + +import scala.concurrent.ExecutionContext + +trait ProxyRoute extends DriverRoute { + implicit val executionContext: ExecutionContext + val config: Config + val httpClient: HttpClient + + protected def proxyToService(serviceName: Name[Service]): Route = { ctx: RequestContext => + val httpScheme = config.getString(s"services.${serviceName.value}.httpScheme") + val baseUrl = config.getString(s"services.${serviceName.value}.baseUrl") + + val originalUri = ctx.request.uri + val originalRequest = ctx.request + + val newUri = originalUri.withScheme(httpScheme).withHost(baseUrl) + val newRequest = originalRequest.withUri(newUri) + + httpClient.makeRequest(newRequest).map(RouteResult.Complete) + } +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/RestService.scala b/core-rest/src/main/scala/xyz/driver/core/rest/RestService.scala new file mode 100644 index 0000000..09d98b8 --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/RestService.scala @@ -0,0 +1,86 @@ +package xyz.driver.core.rest + +import akka.http.scaladsl.model._ +import akka.http.scaladsl.unmarshalling.{Unmarshal, Unmarshaller} +import akka.stream.Materializer + +import scala.concurrent.{ExecutionContext, Future} +import scalaz.{ListT, OptionT} + +trait RestService extends Service { + + import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ + import spray.json._ + + protected implicit val exec: ExecutionContext + protected implicit val materializer: Materializer + + implicit class ResponseEntityFoldable(entity: Unmarshal[ResponseEntity]) { + def fold[T](default: => T)(implicit um: Unmarshaller[ResponseEntity, T]): Future[T] = + if (entity.value.isKnownEmpty()) Future.successful[T](default) else entity.to[T] + } + + protected def unitResponse(request: Future[Unmarshal[ResponseEntity]]): OptionT[Future, Unit] = + OptionT[Future, Unit](request.flatMap(_.to[String]).map(_ => Option(()))) + + protected def optionalResponse[T](request: Future[Unmarshal[ResponseEntity]])( + implicit um: Unmarshaller[ResponseEntity, Option[T]]): OptionT[Future, T] = + OptionT[Future, T](request.flatMap(_.fold(Option.empty[T]))) + + protected def listResponse[T](request: Future[Unmarshal[ResponseEntity]])( + implicit um: Unmarshaller[ResponseEntity, List[T]]): ListT[Future, T] = + ListT[Future, T](request.flatMap(_.fold(List.empty[T]))) + + protected def jsonEntity(json: JsValue): RequestEntity = + HttpEntity(ContentTypes.`application/json`, json.compactPrint) + + protected def mergePatchJsonEntity(json: JsValue): RequestEntity = + HttpEntity(PatchDirectives.`application/merge-patch+json`, json.compactPrint) + + protected def get(baseUri: Uri, path: String, query: Seq[(String, String)] = Seq.empty) = + HttpRequest(HttpMethods.GET, endpointUri(baseUri, path, query)) + + protected def post(baseUri: Uri, path: String, httpEntity: RequestEntity) = + HttpRequest(HttpMethods.POST, endpointUri(baseUri, path), entity = httpEntity) + + protected def postJson(baseUri: Uri, path: String, json: JsValue) = + HttpRequest(HttpMethods.POST, endpointUri(baseUri, path), entity = jsonEntity(json)) + + protected def put(baseUri: Uri, path: String, httpEntity: RequestEntity) = + HttpRequest(HttpMethods.PUT, endpointUri(baseUri, path), entity = httpEntity) + + protected def putJson(baseUri: Uri, path: String, json: JsValue) = + HttpRequest(HttpMethods.PUT, endpointUri(baseUri, path), entity = jsonEntity(json)) + + protected def patch(baseUri: Uri, path: String, httpEntity: RequestEntity) = + HttpRequest(HttpMethods.PATCH, endpointUri(baseUri, path), entity = httpEntity) + + protected def patchJson(baseUri: Uri, path: String, json: JsValue) = + HttpRequest(HttpMethods.PATCH, endpointUri(baseUri, path), entity = jsonEntity(json)) + + protected def mergePatchJson(baseUri: Uri, path: String, json: JsValue) = + HttpRequest(HttpMethods.PATCH, endpointUri(baseUri, path), entity = mergePatchJsonEntity(json)) + + protected def delete(baseUri: Uri, path: String, query: Seq[(String, String)] = Seq.empty) = + HttpRequest(HttpMethods.DELETE, endpointUri(baseUri, path, query)) + + protected def endpointUri(baseUri: Uri, path: String): Uri = + baseUri.withPath(Uri.Path(path)) + + protected def endpointUri(baseUri: Uri, path: String, query: Seq[(String, String)]): Uri = + baseUri.withPath(Uri.Path(path)).withQuery(Uri.Query(query: _*)) + + protected def responseToListResponse[T: JsonFormat](pagination: Option[Pagination])( + response: HttpResponse): Future[ListResponse[T]] = { + import DefaultJsonProtocol._ + val resourceCount = response.headers + .find(_.name() equalsIgnoreCase ContextHeaders.ResourceCount) + .map(_.value().toInt) + .getOrElse(0) + val meta = ListResponse.Meta(resourceCount, pagination.getOrElse(Pagination(resourceCount max 1, 1))) + Unmarshal(response.entity).to[List[T]].map(ListResponse(_, meta)) + } + + protected def responseToListResponse[T: JsonFormat](pagination: Pagination)( + response: HttpResponse): Future[ListResponse[T]] = responseToListResponse(Some(pagination))(response) +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/ServiceDescriptor.scala b/core-rest/src/main/scala/xyz/driver/core/rest/ServiceDescriptor.scala new file mode 100644 index 0000000..646fae8 --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/ServiceDescriptor.scala @@ -0,0 +1,16 @@ +package xyz.driver.core +package rest +import scala.annotation.implicitNotFound + +@implicitNotFound( + "Don't know how to communicate with service ${S}. Make sure an implicit ServiceDescriptor is" + + "available. A good place to put one is in the service's companion object.") +trait ServiceDescriptor[S] { + + /** The service's name. Must be unique among all services. */ + def name: String + + /** Get an instance of the service. */ + def connect(transport: HttpRestServiceTransport, url: String): S + +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/SingleRequestHttpClient.scala b/core-rest/src/main/scala/xyz/driver/core/rest/SingleRequestHttpClient.scala new file mode 100644 index 0000000..964a5a2 --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/SingleRequestHttpClient.scala @@ -0,0 +1,29 @@ +package xyz.driver.core.rest + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.http.scaladsl.model.headers.`User-Agent` +import akka.http.scaladsl.model.{HttpRequest, HttpResponse} +import akka.http.scaladsl.settings.{ClientConnectionSettings, ConnectionPoolSettings} +import akka.stream.ActorMaterializer +import xyz.driver.core.Name + +import scala.concurrent.Future + +class SingleRequestHttpClient(applicationName: Name[App], applicationVersion: String, actorSystem: ActorSystem) + extends HttpClient { + + protected implicit val materializer: ActorMaterializer = ActorMaterializer()(actorSystem) + private val client = Http()(actorSystem) + + private val clientConnectionSettings: ClientConnectionSettings = + ClientConnectionSettings(actorSystem).withUserAgentHeader( + Option(`User-Agent`(applicationName.value + "/" + applicationVersion))) + + private val connectionPoolSettings: ConnectionPoolSettings = ConnectionPoolSettings(actorSystem) + .withConnectionSettings(clientConnectionSettings) + + def makeRequest(request: HttpRequest): Future[HttpResponse] = { + client.singleRequest(request, settings = connectionPoolSettings) + } +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/Swagger.scala b/core-rest/src/main/scala/xyz/driver/core/rest/Swagger.scala new file mode 100644 index 0000000..5ceac54 --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/Swagger.scala @@ -0,0 +1,144 @@ +package xyz.driver.core.rest + +import akka.http.scaladsl.model.{ContentType, ContentTypes, HttpEntity} +import akka.http.scaladsl.server.Route +import akka.http.scaladsl.server.directives.FileAndResourceDirectives.ResourceFile +import akka.stream.ActorAttributes +import akka.stream.scaladsl.{Framing, StreamConverters} +import akka.util.ByteString +import com.github.swagger.akka.SwaggerHttpService +import com.github.swagger.akka.model._ +import com.typesafe.config.Config +import com.typesafe.scalalogging.Logger +import io.swagger.models.Scheme +import io.swagger.models.auth.{ApiKeyAuthDefinition, In} +import io.swagger.util.Json + +import scala.util.control.NonFatal + +class Swagger( + override val host: String, + accessSchemes: List[String], + version: String, + override val apiClasses: Set[Class[_]], + val config: Config, + val logger: Logger) + extends SwaggerHttpService { + + override val schemes = accessSchemes.map { s => + Scheme.forValue(s) + } + + // Note that the reason for overriding this is a subtle chain of causality: + // + // 1. Some of our endpoints require a single trailing slash and will not + // function if it is omitted + // 2. Swagger omits trailing slashes in its generated api doc + // 3. To work around that, a space is added after the trailing slash in the + // swagger Path annotations + // 4. This space is removed manually in the code below + // + // TODO: Ideally we'd like to drop this custom override and fix the issue in + // 1, by dropping the slash requirement and accepting api endpoints with and + // without trailing slashes. This will require inspecting and potentially + // fixing all service endpoints. + override def generateSwaggerJson: String = { + import io.swagger.models.{Swagger => JSwagger} + + import scala.collection.JavaConverters._ + try { + val swagger: JSwagger = reader.read(apiClasses.asJava) + + val paths = if (swagger.getPaths == null) { + Map.empty + } else { + swagger.getPaths.asScala + } + + // Removing trailing spaces + val fixedPaths = paths.map { + case (key, path) => + key.trim -> path + } + + swagger.setPaths(fixedPaths.asJava) + + Json.pretty().writeValueAsString(swagger) + } catch { + case NonFatal(t) => + logger.error("Issue with creating swagger.json", t) + throw t + } + } + + override val securitySchemeDefinitions = Map( + "token" -> { + val definition = new ApiKeyAuthDefinition("Authorization", In.HEADER) + definition.setDescription("Authentication token") + definition + } + ) + + override val basePath: String = config.getString("swagger.basePath") + override val apiDocsPath: String = config.getString("swagger.docsPath") + + override val info = Info( + config.getString("swagger.apiInfo.description"), + version, + config.getString("swagger.apiInfo.title"), + config.getString("swagger.apiInfo.termsOfServiceUrl"), + contact = Some( + Contact( + config.getString("swagger.apiInfo.contact.name"), + config.getString("swagger.apiInfo.contact.url"), + config.getString("swagger.apiInfo.contact.email") + )), + license = Some( + License( + config.getString("swagger.apiInfo.license"), + config.getString("swagger.apiInfo.licenseUrl") + )), + vendorExtensions = Map.empty[String, AnyRef] + ) + + /** A very simple templating extractor. Gets a resource from the classpath and subsitutes any `{{key}}` with a value. */ + private def getTemplatedResource( + resourceName: String, + contentType: ContentType, + substitution: (String, String)): Route = get { + Option(this.getClass.getClassLoader.getResource(resourceName)) flatMap ResourceFile.apply match { + case Some(ResourceFile(url, length @ _, _)) => + extractSettings { settings => + val stream = StreamConverters + .fromInputStream(() => url.openStream()) + .withAttributes(ActorAttributes.dispatcher(settings.fileIODispatcher)) + .via(Framing.delimiter(ByteString("\n"), 4096, true).map(_.utf8String)) + .map { line => + line.replaceAll(s"\\{\\{${substitution._1}\\}\\}", substitution._2) + } + .map(line => ByteString(line + "\n")) + complete( + HttpEntity(contentType, stream) + ) + } + case None => reject + } + } + + def swaggerUI: Route = + pathEndOrSingleSlash { + getTemplatedResource( + "swagger-ui/index.html", + ContentTypes.`text/html(UTF-8)`, + "title" -> config.getString("swagger.apiInfo.title")) + } ~ getFromResourceDirectory("swagger-ui") + + def swaggerUINew: Route = + pathEndOrSingleSlash { + getTemplatedResource( + "swagger-ui-dist/index.html", + ContentTypes.`text/html(UTF-8)`, + "title" -> config.getString("swagger.apiInfo.title")) + } ~ getFromResourceDirectory("swagger-ui-dist") + +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/auth/AlwaysAllowAuthorization.scala b/core-rest/src/main/scala/xyz/driver/core/rest/auth/AlwaysAllowAuthorization.scala new file mode 100644 index 0000000..5007774 --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/auth/AlwaysAllowAuthorization.scala @@ -0,0 +1,14 @@ +package xyz.driver.core.rest.auth + +import xyz.driver.core.auth.{Permission, User} +import xyz.driver.core.rest.ServiceRequestContext + +import scala.concurrent.Future + +class AlwaysAllowAuthorization[U <: User] extends Authorization[U] { + override def userHasPermissions(user: U, permissions: Seq[Permission])( + implicit ctx: ServiceRequestContext): Future[AuthorizationResult] = { + val permissionsMap = permissions.map(_ -> true).toMap + Future.successful(AuthorizationResult(authorized = permissionsMap, ctx.permissionsToken)) + } +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/auth/AuthProvider.scala b/core-rest/src/main/scala/xyz/driver/core/rest/auth/AuthProvider.scala new file mode 100644 index 0000000..e1a94e1 --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/auth/AuthProvider.scala @@ -0,0 +1,75 @@ +package xyz.driver.core.rest.auth + +import akka.http.scaladsl.server.directives.Credentials +import com.typesafe.scalalogging.Logger +import scalaz.OptionT +import xyz.driver.core.auth.{AuthToken, Permission, User} +import xyz.driver.core.rest.errors.{ExternalServiceException, UnauthorizedException} +import xyz.driver.core.rest.{AuthorizedServiceRequestContext, ContextHeaders, ServiceRequestContext, serviceContext} + +import scala.concurrent.{ExecutionContext, Future} + +abstract class AuthProvider[U <: User]( + val authorization: Authorization[U], + log: Logger, + val realm: String +)(implicit execution: ExecutionContext) { + + import akka.http.scaladsl.server._ + import Directives.{authorize => akkaAuthorize, _} + + def this(authorization: Authorization[U], log: Logger)(implicit executionContext: ExecutionContext) = + this(authorization, log, "driver.xyz") + + /** + * Specific implementation on how to extract user from request context, + * can either need to do a network call to auth server or extract everything from self-contained token + * + * @param ctx set of request values which can be relevant to authenticate user + * @return authenticated user + */ + def authenticatedUser(implicit ctx: ServiceRequestContext): OptionT[Future, U] + + protected def authenticator(context: ServiceRequestContext): AsyncAuthenticator[U] = { + case Credentials.Missing => + log.info(s"Request (${context.trackingId}) missing authentication credentials") + Future.successful(None) + case Credentials.Provided(authToken) => + authenticatedUser(context.withAuthToken(AuthToken(authToken))).run.recover({ + case ExternalServiceException(_, _, Some(UnauthorizedException(_))) => None + }) + } + + /** + * Verifies that a user agent is properly authenticated, and (optionally) authorized with the specified permissions + */ + def authorize( + context: ServiceRequestContext, + permissions: Permission*): Directive1[AuthorizedServiceRequestContext[U]] = { + authenticateOAuth2Async[U](realm, authenticator(context)) flatMap { authenticatedUser => + val authCtx = context.withAuthenticatedUser(context.authToken.get, authenticatedUser) + onSuccess(authorization.userHasPermissions(authenticatedUser, permissions)(authCtx)) flatMap { + case AuthorizationResult(authorized, token) => + val allAuthorized = permissions.forall(authorized.getOrElse(_, false)) + akkaAuthorize(allAuthorized) tflatMap { _ => + val cachedPermissionsCtx = token.fold(authCtx)(authCtx.withPermissionsToken) + provide(cachedPermissionsCtx) + } + } + } + } + + /** + * Verifies if request is authenticated and authorized to have `permissions` + */ + def authorize(permissions: Permission*): Directive1[AuthorizedServiceRequestContext[U]] = { + serviceContext flatMap (authorize(_, permissions: _*)) + } +} + +object AuthProvider { + val AuthenticationTokenHeader: String = ContextHeaders.AuthenticationTokenHeader + val PermissionsTokenHeader: String = ContextHeaders.PermissionsTokenHeader + val SetAuthenticationTokenHeader: String = "set-authorization" + val SetPermissionsTokenHeader: String = "set-permissions" +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/auth/Authorization.scala b/core-rest/src/main/scala/xyz/driver/core/rest/auth/Authorization.scala new file mode 100644 index 0000000..1a5e9be --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/auth/Authorization.scala @@ -0,0 +1,11 @@ +package xyz.driver.core.rest.auth + +import xyz.driver.core.auth.{Permission, User} +import xyz.driver.core.rest.ServiceRequestContext + +import scala.concurrent.Future + +trait Authorization[U <: User] { + def userHasPermissions(user: U, permissions: Seq[Permission])( + implicit ctx: ServiceRequestContext): Future[AuthorizationResult] +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/auth/AuthorizationResult.scala b/core-rest/src/main/scala/xyz/driver/core/rest/auth/AuthorizationResult.scala new file mode 100644 index 0000000..efe28c9 --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/auth/AuthorizationResult.scala @@ -0,0 +1,22 @@ +package xyz.driver.core.rest.auth + +import xyz.driver.core.auth.{Permission, PermissionsToken} + +import scalaz.Scalaz.mapMonoid +import scalaz.Semigroup +import scalaz.syntax.semigroup._ + +final case class AuthorizationResult(authorized: Map[Permission, Boolean], token: Option[PermissionsToken]) +object AuthorizationResult { + val unauthorized: AuthorizationResult = AuthorizationResult(authorized = Map.empty, None) + + implicit val authorizationSemigroup: Semigroup[AuthorizationResult] = new Semigroup[AuthorizationResult] { + private implicit val authorizedBooleanSemigroup = Semigroup.instance[Boolean](_ || _) + private implicit val permissionsTokenSemigroup = + Semigroup.instance[Option[PermissionsToken]]((a, b) => b.orElse(a)) + + override def append(a: AuthorizationResult, b: => AuthorizationResult): AuthorizationResult = { + AuthorizationResult(a.authorized |+| b.authorized, a.token |+| b.token) + } + } +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/auth/CachedTokenAuthorization.scala b/core-rest/src/main/scala/xyz/driver/core/rest/auth/CachedTokenAuthorization.scala new file mode 100644 index 0000000..66de4ef --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/auth/CachedTokenAuthorization.scala @@ -0,0 +1,55 @@ +package xyz.driver.core.rest.auth + +import java.nio.file.{Files, Path} +import java.security.{KeyFactory, PublicKey} +import java.security.spec.X509EncodedKeySpec + +import pdi.jwt.{Jwt, JwtAlgorithm} +import xyz.driver.core.auth.{Permission, User} +import xyz.driver.core.rest.ServiceRequestContext + +import scala.concurrent.Future +import scalaz.syntax.std.boolean._ + +class CachedTokenAuthorization[U <: User](publicKey: => PublicKey, issuer: String) extends Authorization[U] { + override def userHasPermissions(user: U, permissions: Seq[Permission])( + implicit ctx: ServiceRequestContext): Future[AuthorizationResult] = { + import spray.json._ + + def extractPermissionsFromTokenJSON(tokenObject: JsObject): Option[Map[String, Boolean]] = + tokenObject.fields.get("permissions").collect { + case JsObject(fields) => + fields.collect { + case (key, JsBoolean(value)) => key -> value + } + } + + val result = for { + token <- ctx.permissionsToken + jwt <- Jwt.decode(token.value, publicKey, Seq(JwtAlgorithm.RS256)).toOption + jwtJson = jwt.parseJson.asJsObject + + // Ensure jwt is for the currently authenticated user and the correct issuer, otherwise return None + _ <- jwtJson.fields.get("sub").contains(JsString(user.id.value)).option(()) + _ <- jwtJson.fields.get("iss").contains(JsString(issuer)).option(()) + + permissionsMap <- extractPermissionsFromTokenJSON(jwtJson) + + authorized = permissions.map(p => p -> permissionsMap.getOrElse(p.toString, false)).toMap + } yield AuthorizationResult(authorized, Some(token)) + + Future.successful(result.getOrElse(AuthorizationResult.unauthorized)) + } +} + +object CachedTokenAuthorization { + def apply[U <: User](publicKeyFile: Path, issuer: String): CachedTokenAuthorization[U] = { + lazy val publicKey: PublicKey = { + val publicKeyBase64Encoded = new String(Files.readAllBytes(publicKeyFile)).trim + val publicKeyBase64Decoded = java.util.Base64.getDecoder.decode(publicKeyBase64Encoded) + val spec = new X509EncodedKeySpec(publicKeyBase64Decoded) + KeyFactory.getInstance("RSA").generatePublic(spec) + } + new CachedTokenAuthorization[U](publicKey, issuer) + } +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/auth/ChainedAuthorization.scala b/core-rest/src/main/scala/xyz/driver/core/rest/auth/ChainedAuthorization.scala new file mode 100644 index 0000000..131e7fc --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/auth/ChainedAuthorization.scala @@ -0,0 +1,27 @@ +package xyz.driver.core.rest.auth + +import xyz.driver.core.auth.{Permission, User} +import xyz.driver.core.rest.ServiceRequestContext + +import scala.concurrent.{ExecutionContext, Future} +import scalaz.Scalaz.{futureInstance, listInstance} +import scalaz.syntax.semigroup._ +import scalaz.syntax.traverse._ + +class ChainedAuthorization[U <: User](authorizations: Authorization[U]*)(implicit execution: ExecutionContext) + extends Authorization[U] { + + override def userHasPermissions(user: U, permissions: Seq[Permission])( + implicit ctx: ServiceRequestContext): Future[AuthorizationResult] = { + def allAuthorized(permissionsMap: Map[Permission, Boolean]): Boolean = + permissions.forall(permissionsMap.getOrElse(_, false)) + + authorizations.toList.foldLeftM[Future, AuthorizationResult](AuthorizationResult.unauthorized) { + (authResult, authorization) => + if (allAuthorized(authResult.authorized)) Future.successful(authResult) + else { + authorization.userHasPermissions(user, permissions).map(authResult |+| _) + } + } + } +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/directives/AuthDirectives.scala b/core-rest/src/main/scala/xyz/driver/core/rest/directives/AuthDirectives.scala new file mode 100644 index 0000000..ff3424d --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/directives/AuthDirectives.scala @@ -0,0 +1,19 @@ +package xyz.driver.core +package rest +package directives + +import akka.http.scaladsl.server.{Directive1, Directives => AkkaDirectives} +import xyz.driver.core.auth.{Permission, User} +import xyz.driver.core.rest.auth.AuthProvider + +/** Authentication and authorization directives. */ +trait AuthDirectives extends AkkaDirectives { + + /** Authenticate a user based on service request headers and check if they have all given permissions. */ + def authenticateAndAuthorize[U <: User]( + authProvider: AuthProvider[U], + permissions: Permission*): Directive1[AuthorizedServiceRequestContext[U]] = { + authProvider.authorize(permissions: _*) + } + +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/directives/CorsDirectives.scala b/core-rest/src/main/scala/xyz/driver/core/rest/directives/CorsDirectives.scala new file mode 100644 index 0000000..5a6bbfd --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/directives/CorsDirectives.scala @@ -0,0 +1,72 @@ +package xyz.driver.core +package rest +package directives + +import akka.http.scaladsl.model.HttpMethods._ +import akka.http.scaladsl.model.headers._ +import akka.http.scaladsl.model.{HttpResponse, StatusCodes} +import akka.http.scaladsl.server.{Route, Directives => AkkaDirectives} + +/** Directives to handle Cross-Origin Resource Sharing (CORS). */ +trait CorsDirectives extends AkkaDirectives { + + /** Route handler that injects Cross-Origin Resource Sharing (CORS) headers depending on the request + * origin. + * + * In a microservice environment, it can be difficult to know in advance the exact origin + * from which requests may be issued [1]. For example, the request may come from a web page served from + * any of the services, on any namespace or from other documentation sites. In general, only a set + * of domain suffixes can be assumed to be known in advance. Unfortunately however, browsers that + * implement CORS require exact specification of allowed origins, including full host name and scheme, + * in order to send credentials and headers with requests to other origins. + * + * This route wrapper provides a simple way alleviate CORS' exact allowed-origin requirement by + * dynamically echoing the origin as an allowed origin if and only if its domain is whitelisted. + * + * Note that the simplicity of this implementation comes with two notable drawbacks: + * + * - All OPTION requests are "hijacked" and will not be passed to the inner route of this wrapper. + * + * - Allowed methods and headers can not be customized on a per-request basis. All standard + * HTTP methods are allowed, and allowed headers are specified for all inner routes. + * + * This handler is not suited for cases where more fine-grained control of responses is required. + * + * [1] Assuming browsers communicate directly with the services and that requests aren't proxied through + * a common gateway. + * + * @param allowedSuffixes The set of domain suffixes (e.g. internal.example.org, example.org) of allowed + * origins. + * @param allowedHeaders Header names that will be set in `Access-Control-Allow-Headers`. + * @param inner Route into which CORS headers will be injected. + */ + def cors(allowedSuffixes: Set[String], allowedHeaders: Seq[String])(inner: Route): Route = { + optionalHeaderValueByType[Origin](()) { maybeOrigin => + val allowedOrigins: HttpOriginRange = maybeOrigin match { + // Note that this is not a security issue: the client will never send credentials if the allowed + // origin is set to *. This case allows us to deal with clients that do not send an origin header. + case None => HttpOriginRange.* + case Some(requestOrigin) => + val allowedOrigin = requestOrigin.origins.find(origin => + allowedSuffixes.exists(allowed => origin.host.host.address endsWith allowed)) + allowedOrigin.map(HttpOriginRange(_)).getOrElse(HttpOriginRange.*) + } + + respondWithHeaders( + `Access-Control-Allow-Origin`.forRange(allowedOrigins), + `Access-Control-Allow-Credentials`(true), + `Access-Control-Allow-Headers`(allowedHeaders: _*), + `Access-Control-Expose-Headers`(allowedHeaders: _*) + ) { + options { // options is used during preflight check + complete( + HttpResponse(StatusCodes.OK) + .withHeaders(`Access-Control-Allow-Methods`(OPTIONS, POST, PUT, GET, DELETE, PATCH, TRACE))) + } ~ inner // in case of non-preflight check we don't do anything special + } + } + } + +} + +object CorsDirectives extends CorsDirectives diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/directives/Directives.scala b/core-rest/src/main/scala/xyz/driver/core/rest/directives/Directives.scala new file mode 100644 index 0000000..0cd4ef1 --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/directives/Directives.scala @@ -0,0 +1,6 @@ +package xyz.driver.core +package rest +package directives + +trait Directives extends AuthDirectives with CorsDirectives with PathMatchers with Unmarshallers +object Directives extends Directives diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/directives/PathMatchers.scala b/core-rest/src/main/scala/xyz/driver/core/rest/directives/PathMatchers.scala new file mode 100644 index 0000000..218c9ae --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/directives/PathMatchers.scala @@ -0,0 +1,85 @@ +package xyz.driver.core +package rest +package directives + +import java.time.Instant +import java.util.UUID + +import akka.http.scaladsl.model.Uri.Path +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 + +/** Akka-HTTP path matchers for custom core types. */ +trait PathMatchers { + + private def UuidInPath[T]: PathMatcher1[Id[T]] = + AkkaPathMatchers.JavaUUID.map((id: UUID) => Id[T](id.toString.toLowerCase)) + + def IdInPath[T]: PathMatcher1[Id[T]] = UuidInPath[T] | new PathMatcher1[Id[T]] { + def apply(path: Path) = path match { + case Path.Segment(segment, tail) => Matched(tail, Tuple1(Id[T](segment))) + case _ => Unmatched + } + } + + def NameInPath[T]: PathMatcher1[Name[T]] = new PathMatcher1[Name[T]] { + def apply(path: Path) = path match { + case Path.Segment(segment, tail) => Matched(tail, Tuple1(Name[T](segment))) + case _ => Unmatched + } + } + + private def timestampInPath: PathMatcher1[Long] = + PathMatcher("""[+-]?\d*""".r) flatMap { string => + try Some(string.toLong) + catch { case _: IllegalArgumentException => None } + } + + def InstantInPath: PathMatcher1[Instant] = + new PathMatcher1[Instant] { + def apply(path: Path): PathMatcher.Matching[Tuple1[Instant]] = path match { + case Path.Segment(head, tail) => + try Matched(tail, Tuple1(Instant.parse(head))) + catch { + case NonFatal(_) => Unmatched + } + case _ => Unmatched + } + } | timestampInPath.map(Instant.ofEpochMilli) + + def TimeInPath: PathMatcher1[Time] = InstantInPath.map(instant => Time(instant.toEpochMilli)) + + def NonEmptyNameInPath[T]: PathMatcher1[NonEmptyName[T]] = new PathMatcher1[NonEmptyName[T]] { + def apply(path: Path) = path match { + case Path.Segment(segment, tail) => + refineV[NonEmpty](segment) match { + case Left(_) => Unmatched + case Right(nonEmptyString) => Matched(tail, Tuple1(NonEmptyName[T](nonEmptyString))) + } + case _ => Unmatched + } + } + + def RevisionInPath[T]: PathMatcher1[Revision[T]] = + PathMatcher("""[\da-fA-F]{8}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{12}""".r) flatMap { string => + 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/core-rest/src/main/scala/xyz/driver/core/rest/directives/Unmarshallers.scala b/core-rest/src/main/scala/xyz/driver/core/rest/directives/Unmarshallers.scala new file mode 100644 index 0000000..6c45d15 --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/directives/Unmarshallers.scala @@ -0,0 +1,40 @@ +package xyz.driver.core +package rest +package directives + +import java.util.UUID + +import akka.http.scaladsl.marshalling.{Marshaller, Marshalling} +import akka.http.scaladsl.unmarshalling.Unmarshaller +import spray.json.{JsString, JsValue, JsonParser, JsonReader, JsonWriter} + +/** Akka-HTTP unmarshallers for custom core types. */ +trait Unmarshallers { + + implicit def idUnmarshaller[A]: Unmarshaller[String, Id[A]] = + Unmarshaller.strict[String, Id[A]] { str => + Id[A](UUID.fromString(str).toString) + } + + implicit def paramUnmarshaller[T](implicit reader: JsonReader[T]): Unmarshaller[String, T] = + Unmarshaller.firstOf( + Unmarshaller.strict((JsString(_: String)) andThen reader.read), + stringToValueUnmarshaller[T] + ) + + implicit def revisionFromStringUnmarshaller[T]: Unmarshaller[String, Revision[T]] = + Unmarshaller.strict[String, Revision[T]](Revision[T]) + + val jsValueToStringMarshaller: Marshaller[JsValue, String] = + Marshaller.strict[JsValue, String](value => Marshalling.Opaque[String](() => value.compactPrint)) + + def valueToStringMarshaller[T](implicit jsonFormat: JsonWriter[T]): Marshaller[T, String] = + jsValueToStringMarshaller.compose[T](jsonFormat.write) + + val stringToJsValueUnmarshaller: Unmarshaller[String, JsValue] = + Unmarshaller.strict[String, JsValue](value => JsonParser(value)) + + def stringToValueUnmarshaller[T](implicit jsonFormat: JsonReader[T]): Unmarshaller[String, T] = + stringToJsValueUnmarshaller.map[T](jsonFormat.read) + +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala b/core-rest/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala new file mode 100644 index 0000000..f2962c9 --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala @@ -0,0 +1,27 @@ +package xyz.driver.core.rest.errors + +sealed abstract class ServiceException(val message: String) extends Exception(message) + +final case class InvalidInputException(override val message: String = "Invalid input") extends ServiceException(message) + +final case class InvalidActionException(override val message: String = "This action is not allowed") + extends ServiceException(message) + +final case class UnauthorizedException( + override val message: String = "The user's authentication credentials are invalid or missing") + extends ServiceException(message) + +final case class ResourceNotFoundException(override val message: String = "Resource not found") + extends ServiceException(message) + +final case class ExternalServiceException( + serviceName: String, + serviceMessage: String, + serviceException: Option[ServiceException]) + extends ServiceException(s"Error while calling '$serviceName': $serviceMessage") + +final case class ExternalServiceTimeoutException(serviceName: String) + extends ServiceException(s"$serviceName took too long to respond") + +final case class DatabaseException(override val message: String = "Database access error") + extends ServiceException(message) diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/headers/Traceparent.scala b/core-rest/src/main/scala/xyz/driver/core/rest/headers/Traceparent.scala new file mode 100644 index 0000000..866476d --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/headers/Traceparent.scala @@ -0,0 +1,33 @@ +package xyz.driver.core +package rest +package headers + +import akka.http.scaladsl.model.headers.{ModeledCustomHeader, ModeledCustomHeaderCompanion} +import xyz.driver.core.reporting.SpanContext + +import scala.util.Try + +/** Encapsulates a trace context in an HTTP header for propagation across services. + * + * This implementation corresponds to the W3C editor's draft specification (as of 2018-08-28) + * https://w3c.github.io/distributed-tracing/report-trace-context.html. The 'flags' field is + * ignored. + */ +final case class Traceparent(spanContext: SpanContext) extends ModeledCustomHeader[Traceparent] { + override def renderInRequests = true + override def renderInResponses = true + override val companion: Traceparent.type = Traceparent + override def value: String = f"01-${spanContext.traceId}-${spanContext.spanId}-00" +} +object Traceparent extends ModeledCustomHeaderCompanion[Traceparent] { + override val name = "traceparent" + override def parse(value: String) = Try { + val Array(version, traceId, spanId, _) = value.split("-") + require( + version == "01", + s"Found unsupported version '$version' in traceparent header. Only version '01' is supported.") + new Traceparent( + new SpanContext(traceId, spanId) + ) + } +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/package.scala b/core-rest/src/main/scala/xyz/driver/core/rest/package.scala new file mode 100644 index 0000000..34a4a9d --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/package.scala @@ -0,0 +1,323 @@ +package xyz.driver.core.rest + +import java.net.InetAddress + +import akka.http.scaladsl.marshalling.{ToEntityMarshaller, ToResponseMarshallable} +import akka.http.scaladsl.model._ +import akka.http.scaladsl.model.headers._ +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server._ +import akka.http.scaladsl.unmarshalling.Unmarshal +import akka.stream.Materializer +import akka.stream.scaladsl.Flow +import akka.util.ByteString +import scalaz.Scalaz.{intInstance, stringInstance} +import scalaz.syntax.equal._ +import scalaz.{Functor, OptionT} +import xyz.driver.core.rest.auth.AuthProvider +import xyz.driver.core.rest.errors.ExternalServiceException +import xyz.driver.core.rest.headers.Traceparent +import xyz.driver.tracing.TracingDirectives + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.Try + +trait Service + +object Service + +trait HttpClient { + def makeRequest(request: HttpRequest): Future[HttpResponse] +} + +trait ServiceTransport { + + def sendRequestGetResponse(context: ServiceRequestContext)(requestStub: HttpRequest): Future[HttpResponse] + + def sendRequest(context: ServiceRequestContext)(requestStub: HttpRequest)( + implicit mat: Materializer): Future[Unmarshal[ResponseEntity]] +} + +sealed trait SortingOrder +object SortingOrder { + case object Asc extends SortingOrder + case object Desc extends SortingOrder +} + +final case class SortingField(name: String, sortingOrder: SortingOrder) +final case class Sorting(sortingFields: Seq[SortingField]) + +final case class Pagination(pageSize: Int, pageNumber: Int) { + require(pageSize > 0, "Page size must be greater than zero") + require(pageNumber > 0, "Page number must be greater than zero") + + def offset: Int = pageSize * (pageNumber - 1) +} + +final case class ListResponse[+T](items: Seq[T], meta: ListResponse.Meta) + +object ListResponse { + + def apply[T](items: Seq[T], size: Int, pagination: Option[Pagination]): ListResponse[T] = + ListResponse( + items = items, + meta = ListResponse.Meta(size, pagination.fold(1)(_.pageNumber), pagination.fold(size)(_.pageSize))) + + final case class Meta(itemsCount: Int, pageNumber: Int, pageSize: Int) + + object Meta { + def apply(itemsCount: Int, pagination: Pagination): Meta = + Meta(itemsCount, pagination.pageNumber, pagination.pageSize) + } + +} + +object `package` { + + implicit class FutureExtensions[T](future: Future[T]) { + def passThroughExternalServiceException(implicit executionContext: ExecutionContext): Future[T] = + future.transform(identity, { + case ExternalServiceException(_, _, Some(e)) => e + case t: Throwable => t + }) + } + + implicit class OptionTRestAdditions[T](optionT: OptionT[Future, T]) { + def responseOrNotFound(successCode: StatusCodes.Success = StatusCodes.OK)( + implicit F: Functor[Future], + em: ToEntityMarshaller[T]): Future[ToResponseMarshallable] = { + optionT.fold[ToResponseMarshallable](successCode -> _, StatusCodes.NotFound -> None) + } + } + + object ContextHeaders { + val AuthenticationTokenHeader: String = "Authorization" + val PermissionsTokenHeader: String = "Permissions" + val AuthenticationHeaderPrefix: String = "Bearer" + val ClientFingerprintHeader: String = "X-Client-Fingerprint" + val TrackingIdHeader: String = "X-Trace" + val StacktraceHeader: String = "X-Stacktrace" + val OriginatingIpHeader: String = "X-Forwarded-For" + val ResourceCount: String = "X-Resource-Count" + val PageCount: String = "X-Page-Count" + val TraceHeaderName: String = TracingDirectives.TraceHeaderName + val SpanHeaderName: String = TracingDirectives.SpanHeaderName + } + + val AllowedHeaders: Seq[String] = + Seq( + "Origin", + "X-Requested-With", + "Content-Type", + "Content-Length", + "Accept", + "X-Trace", + "Access-Control-Allow-Methods", + "Access-Control-Allow-Origin", + "Access-Control-Allow-Headers", + "Server", + "Date", + ContextHeaders.ClientFingerprintHeader, + ContextHeaders.TrackingIdHeader, + ContextHeaders.TraceHeaderName, + ContextHeaders.SpanHeaderName, + ContextHeaders.StacktraceHeader, + ContextHeaders.AuthenticationTokenHeader, + ContextHeaders.OriginatingIpHeader, + ContextHeaders.ResourceCount, + ContextHeaders.PageCount, + "X-Frame-Options", + "X-Content-Type-Options", + "Strict-Transport-Security", + AuthProvider.SetAuthenticationTokenHeader, + AuthProvider.SetPermissionsTokenHeader, + "Traceparent" + ) + + def allowOrigin(originHeader: Option[Origin]): `Access-Control-Allow-Origin` = + `Access-Control-Allow-Origin`( + originHeader.fold[HttpOriginRange](HttpOriginRange.*)(h => HttpOriginRange(h.origins: _*))) + + def serviceContext: Directive1[ServiceRequestContext] = { + def fixAuthorizationHeader(headers: Seq[HttpHeader]): collection.immutable.Seq[HttpHeader] = { + headers.map({ header => + if (header.name === ContextHeaders.AuthenticationTokenHeader && !header.value.startsWith( + ContextHeaders.AuthenticationHeaderPrefix)) { + Authorization(OAuth2BearerToken(header.value)) + } else header + })(collection.breakOut) + } + extractClientIP flatMap { remoteAddress => + mapRequest(req => req.withHeaders(fixAuthorizationHeader(req.headers))) tflatMap { _ => + extract(ctx => extractServiceContext(ctx.request, remoteAddress)) + } + } + } + + def respondWithCorsAllowedHeaders: Directive0 = { + respondWithHeaders( + List[HttpHeader]( + `Access-Control-Allow-Headers`(AllowedHeaders: _*), + `Access-Control-Expose-Headers`(AllowedHeaders: _*) + )) + } + + def respondWithCorsAllowedOriginHeaders(origin: Origin): Directive0 = { + respondWithHeader { + `Access-Control-Allow-Origin`(HttpOriginRange(origin.origins: _*)) + } + } + + def respondWithCorsAllowedMethodHeaders(methods: Set[HttpMethod]): Directive0 = { + respondWithHeaders( + List[HttpHeader]( + Allow(methods.to[collection.immutable.Seq]), + `Access-Control-Allow-Methods`(methods.to[collection.immutable.Seq]) + )) + } + + def extractServiceContext(request: HttpRequest, remoteAddress: RemoteAddress): ServiceRequestContext = + new ServiceRequestContext( + extractTrackingId(request), + extractOriginatingIP(request, remoteAddress), + extractContextHeaders(request)) + + def extractTrackingId(request: HttpRequest): String = { + request.headers + .find(_.name === ContextHeaders.TrackingIdHeader) + .fold(java.util.UUID.randomUUID.toString)(_.value()) + } + + def extractFingerprintHash(request: HttpRequest): Option[String] = { + request.headers + .find(_.name === ContextHeaders.ClientFingerprintHeader) + .map(_.value()) + } + + def extractOriginatingIP(request: HttpRequest, remoteAddress: RemoteAddress): Option[InetAddress] = { + request.headers + .find(_.name === ContextHeaders.OriginatingIpHeader) + .flatMap(ipName => Try(InetAddress.getByName(ipName.value)).toOption) + .orElse(remoteAddress.toOption) + } + + def extractStacktrace(request: HttpRequest): Array[String] = + request.headers.find(_.name == ContextHeaders.StacktraceHeader).fold("")(_.value()).split("->") + + def extractContextHeaders(request: HttpRequest): Map[String, String] = { + request.headers + .filter { h => + h.name === ContextHeaders.AuthenticationTokenHeader || h.name === ContextHeaders.TrackingIdHeader || + h.name === ContextHeaders.PermissionsTokenHeader || h.name === ContextHeaders.StacktraceHeader || + h.name === ContextHeaders.TraceHeaderName || h.name === ContextHeaders.SpanHeaderName || + h.name === ContextHeaders.OriginatingIpHeader || h.name === ContextHeaders.ClientFingerprintHeader || + h.name === Traceparent.name + } + .map { header => + if (header.name === ContextHeaders.AuthenticationTokenHeader) { + header.name -> header.value.stripPrefix(ContextHeaders.AuthenticationHeaderPrefix).trim + } else { + header.name -> header.value + } + } + .toMap + } + + private[rest] def escapeScriptTags(byteString: ByteString): ByteString = { + @annotation.tailrec + def dirtyIndices(from: Int, descIndices: List[Int]): List[Int] = { + val index = byteString.indexOf('/', from) + if (index === -1) descIndices.reverse + else { + val (init, tail) = byteString.splitAt(index) + if ((init endsWith "<") && (tail startsWith "/sc")) { + dirtyIndices(index + 1, index :: descIndices) + } else { + dirtyIndices(index + 1, descIndices) + } + } + } + + val indices = dirtyIndices(0, Nil) + + indices.headOption.fold(byteString) { head => + val builder = ByteString.newBuilder + builder ++= byteString.take(head) + + (indices :+ byteString.length).sliding(2).foreach { + case Seq(start, end) => + builder += ' ' + builder ++= byteString.slice(start, end) + case Seq(_) => // Should not match; sliding on at least 2 elements + assert(indices.nonEmpty, s"Indices should have been nonEmpty: $indices") + } + builder.result + } + } + + val sanitizeRequestEntity: Directive0 = { + mapRequest(request => request.mapEntity(entity => entity.transformDataBytes(Flow.fromFunction(escapeScriptTags)))) + } + + val paginated: Directive1[Pagination] = + parameters(("pageSize".as[Int] ? 100, "pageNumber".as[Int] ? 1)).as(Pagination) + + private def extractPagination(pageSizeOpt: Option[Int], pageNumberOpt: Option[Int]): Option[Pagination] = + (pageSizeOpt, pageNumberOpt) match { + case (Some(size), Some(number)) => Option(Pagination(size, number)) + case (None, None) => Option.empty[Pagination] + case (_, _) => throw new IllegalArgumentException("Pagination's parameters are incorrect") + } + + val optionalPagination: Directive1[Option[Pagination]] = + parameters(("pageSize".as[Int].?, "pageNumber".as[Int].?)).as(extractPagination) + + def paginationQuery(pagination: Pagination) = + Seq("pageNumber" -> pagination.pageNumber.toString, "pageSize" -> pagination.pageSize.toString) + + def completeWithPagination[T](handler: Option[Pagination] => Future[ListResponse[T]])( + implicit marshaller: ToEntityMarshaller[Seq[T]]): Route = { + optionalPagination { pagination => + onSuccess(handler(pagination)) { + case ListResponse(resultPart, ListResponse.Meta(count, _, pageSize)) => + val pageCount = if (pageSize == 0) 0 else (count / pageSize) + (if (count % pageSize == 0) 0 else 1) + val headers = List( + RawHeader(ContextHeaders.ResourceCount, count.toString), + RawHeader(ContextHeaders.PageCount, pageCount.toString)) + + respondWithHeaders(headers)(complete(ToResponseMarshallable(resultPart))) + } + } + } + + private def extractSorting(sortingString: Option[String]): Sorting = { + val sortingFields = sortingString.fold(Seq.empty[SortingField])( + _.split(",") + .filter(_.length > 0) + .map { sortingParam => + if (sortingParam.startsWith("-")) { + SortingField(sortingParam.substring(1), SortingOrder.Desc) + } else { + val fieldName = if (sortingParam.startsWith("+")) sortingParam.substring(1) else sortingParam + SortingField(fieldName, SortingOrder.Asc) + } + } + .toSeq) + + Sorting(sortingFields) + } + + val sorting: Directive1[Sorting] = parameter("sort".as[String].?).as(extractSorting) + + def sortingQuery(sorting: Sorting): Seq[(String, String)] = { + val sortingString = sorting.sortingFields + .map { sortingField => + sortingField.sortingOrder match { + case SortingOrder.Asc => sortingField.name + case SortingOrder.Desc => s"-${sortingField.name}" + } + } + .mkString(",") + Seq("sort" -> sortingString) + } +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/serviceDiscovery.scala b/core-rest/src/main/scala/xyz/driver/core/rest/serviceDiscovery.scala new file mode 100644 index 0000000..55f1a2e --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/serviceDiscovery.scala @@ -0,0 +1,24 @@ +package xyz.driver.core.rest + +import xyz.driver.core.Name + +trait ServiceDiscovery { + + def discover[T <: Service](serviceName: Name[Service]): T +} + +trait SavingUsedServiceDiscovery { + private val usedServices = new scala.collection.mutable.HashSet[String]() + + def saveServiceUsage(serviceName: Name[Service]): Unit = usedServices.synchronized { + usedServices += serviceName.value + } + + def getUsedServices: Set[String] = usedServices.synchronized { usedServices.toSet } +} + +class NoServiceDiscovery extends ServiceDiscovery with SavingUsedServiceDiscovery { + + def discover[T <: Service](serviceName: Name[Service]): T = + throw new IllegalArgumentException(s"Service with name $serviceName is unknown") +} diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/serviceRequestContext.scala b/core-rest/src/main/scala/xyz/driver/core/rest/serviceRequestContext.scala new file mode 100644 index 0000000..d2e4bc3 --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/rest/serviceRequestContext.scala @@ -0,0 +1,87 @@ +package xyz.driver.core.rest + +import java.net.InetAddress + +import xyz.driver.core.auth.{AuthToken, PermissionsToken, User} +import xyz.driver.core.generators +import scalaz.Scalaz.{mapEqual, stringInstance} +import scalaz.syntax.equal._ +import xyz.driver.core.reporting.SpanContext +import xyz.driver.core.rest.auth.AuthProvider +import xyz.driver.core.rest.headers.Traceparent + +import scala.util.Try + +class ServiceRequestContext( + val trackingId: String = generators.nextUuid().toString, + val originatingIp: Option[InetAddress] = None, + val contextHeaders: Map[String, String] = Map.empty[String, String]) { + def authToken: Option[AuthToken] = + contextHeaders.get(AuthProvider.AuthenticationTokenHeader).map(AuthToken.apply) + + def permissionsToken: Option[PermissionsToken] = + contextHeaders.get(AuthProvider.PermissionsTokenHeader).map(PermissionsToken.apply) + + def withAuthToken(authToken: AuthToken): ServiceRequestContext = + new ServiceRequestContext( + trackingId, + originatingIp, + contextHeaders.updated(AuthProvider.AuthenticationTokenHeader, authToken.value) + ) + + def withAuthenticatedUser[U <: User](authToken: AuthToken, user: U): AuthorizedServiceRequestContext[U] = + new AuthorizedServiceRequestContext( + trackingId, + originatingIp, + contextHeaders.updated(AuthProvider.AuthenticationTokenHeader, authToken.value), + user + ) + + override def hashCode(): Int = + Seq[Any](trackingId, originatingIp, contextHeaders) + .foldLeft(31)((result, obj) => 31 * result + obj.hashCode()) + + override def equals(obj: Any): Boolean = obj match { + case ctx: ServiceRequestContext => + trackingId === ctx.trackingId && + originatingIp == originatingIp && + contextHeaders === ctx.contextHeaders + case _ => false + } + + def spanContext: SpanContext = { + val validHeader = Try { + contextHeaders(Traceparent.name) + }.flatMap { value => + Traceparent.parse(value) + } + validHeader.map(_.spanContext).getOrElse(SpanContext.fresh()) + } + + override def toString: String = s"ServiceRequestContext($trackingId, $contextHeaders)" +} + +class AuthorizedServiceRequestContext[U <: User]( + override val trackingId: String = generators.nextUuid().toString, + override val originatingIp: Option[InetAddress] = None, + override val contextHeaders: Map[String, String] = Map.empty[String, String], + val authenticatedUser: U) + extends ServiceRequestContext { + + def withPermissionsToken(permissionsToken: PermissionsToken): AuthorizedServiceRequestContext[U] = + new AuthorizedServiceRequestContext[U]( + trackingId, + originatingIp, + contextHeaders.updated(AuthProvider.PermissionsTokenHeader, permissionsToken.value), + authenticatedUser) + + override def hashCode(): Int = 31 * super.hashCode() + authenticatedUser.hashCode() + + override def equals(obj: Any): Boolean = obj match { + case ctx: AuthorizedServiceRequestContext[U] => super.equals(ctx) && ctx.authenticatedUser == authenticatedUser + case _ => false + } + + override def toString: String = + s"AuthorizedServiceRequestContext($trackingId, $contextHeaders, $authenticatedUser)" +} diff --git a/core-rest/src/test/scala/xyz/driver/core/AuthTest.scala b/core-rest/src/test/scala/xyz/driver/core/AuthTest.scala new file mode 100644 index 0000000..2e772fb --- /dev/null +++ b/core-rest/src/test/scala/xyz/driver/core/AuthTest.scala @@ -0,0 +1,165 @@ +package xyz.driver.core + +import akka.http.scaladsl.model.headers.{ + HttpChallenges, + OAuth2BearerToken, + RawHeader, + Authorization => AkkaAuthorization +} +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server._ +import akka.http.scaladsl.testkit.ScalatestRouteTest +import org.scalatest.{FlatSpec, Matchers} +import pdi.jwt.{Jwt, JwtAlgorithm} +import xyz.driver.core.auth._ +import xyz.driver.core.domain.Email +import xyz.driver.core.logging._ +import xyz.driver.core.rest._ +import xyz.driver.core.rest.auth._ +import xyz.driver.core.time.Time + +import scala.concurrent.Future +import scalaz.OptionT + +class AuthTest extends FlatSpec with Matchers with ScalatestRouteTest { + + case object TestRoleAllowedPermission extends Permission + case object TestRoleAllowedByTokenPermission extends Permission + case object TestRoleNotAllowedPermission extends Permission + + val TestRole = Role(Id("1"), Name("testRole")) + + val (publicKey, privateKey) = { + import java.security.KeyPairGenerator + + val keygen = KeyPairGenerator.getInstance("RSA") + keygen.initialize(2048) + + val keyPair = keygen.generateKeyPair() + (keyPair.getPublic, keyPair.getPrivate) + } + + val basicAuthorization: Authorization[User] = new Authorization[User] { + + override def userHasPermissions(user: User, permissions: Seq[Permission])( + implicit ctx: ServiceRequestContext): Future[AuthorizationResult] = { + val authorized = permissions.map(p => p -> (p === TestRoleAllowedPermission)).toMap + Future.successful(AuthorizationResult(authorized, ctx.permissionsToken)) + } + } + + val tokenIssuer = "users" + val tokenAuthorization = new CachedTokenAuthorization[User](publicKey, tokenIssuer) + + val authorization = new ChainedAuthorization[User](tokenAuthorization, basicAuthorization) + + val authStatusService = new AuthProvider[User](authorization, NoLogger) { + override def authenticatedUser(implicit ctx: ServiceRequestContext): OptionT[Future, User] = + OptionT.optionT[Future] { + if (ctx.contextHeaders.keySet.contains(AuthProvider.AuthenticationTokenHeader)) { + Future.successful( + Some( + AuthTokenUserInfo( + Id[User]("1"), + Email("foo", "bar"), + emailVerified = true, + audience = "driver", + roles = Set(TestRole), + expirationTime = Time(1000000L) + ))) + } else { + Future.successful(Option.empty[User]) + } + } + } + + import authStatusService._ + + "'authorize' directive" should "throw error if auth token is not in the request" in { + + Get("/naive/attempt") ~> + authorize(TestRoleAllowedPermission) { user => + complete("Never going to be here") + } ~> + check { + // handled shouldBe false + rejections should contain( + AuthenticationFailedRejection( + AuthenticationFailedRejection.CredentialsMissing, + HttpChallenges.oAuth2(authStatusService.realm))) + } + } + + it should "throw error if authorized user does not have the requested permission" in { + + val referenceAuthToken = AuthToken("I am a test role's token") + val referenceAuthHeader = AkkaAuthorization(OAuth2BearerToken(referenceAuthToken.value)) + + Post("/administration/attempt").addHeader( + referenceAuthHeader + ) ~> + authorize(TestRoleNotAllowedPermission) { user => + complete("Never going to get here") + } ~> + check { + handled shouldBe false + rejections should contain(AuthorizationFailedRejection) + } + } + + it should "pass and retrieve the token to client code, if token is in request and user has permission" in { + val referenceAuthToken = AuthToken("I am token") + val referenceAuthHeader = AkkaAuthorization(OAuth2BearerToken(referenceAuthToken.value)) + + Get("/valid/attempt/?a=2&b=5").addHeader( + referenceAuthHeader + ) ~> + authorize(TestRoleAllowedPermission) { ctx => + complete(s"Alright, user ${ctx.authenticatedUser.id} is authorized") + } ~> + check { + handled shouldBe true + responseAs[String] shouldBe "Alright, user 1 is authorized" + } + } + + it should "authenticate correctly even without the 'Bearer' prefix on the Authorization header" in { + val referenceAuthToken = AuthToken("unprefixed_token") + + Get("/valid/attempt/?a=2&b=5").addHeader( + RawHeader(ContextHeaders.AuthenticationTokenHeader, referenceAuthToken.value) + ) ~> + authorize(TestRoleAllowedPermission) { ctx => + complete(s"Alright, user ${ctx.authenticatedUser.id} is authorized") + } ~> + check { + handled shouldBe true + responseAs[String] shouldBe "Alright, user 1 is authorized" + } + } + + it should "authorize permission found in permissions token" in { + import spray.json._ + + val claim = JsObject( + Map( + "iss" -> JsString(tokenIssuer), + "sub" -> JsString("1"), + "permissions" -> JsObject(Map(TestRoleAllowedByTokenPermission.toString -> JsBoolean(true))) + )).prettyPrint + val permissionsToken = PermissionsToken(Jwt.encode(claim, privateKey, JwtAlgorithm.RS256)) + val referenceAuthToken = AuthToken("I am token") + val referenceAuthHeader = AkkaAuthorization(OAuth2BearerToken(referenceAuthToken.value)) + + Get("/alic/attempt/?a=2&b=5") + .addHeader(referenceAuthHeader) + .addHeader(RawHeader(AuthProvider.PermissionsTokenHeader, permissionsToken.value)) ~> + authorize(TestRoleAllowedByTokenPermission) { ctx => + complete(s"Alright, user ${ctx.authenticatedUser.id} is authorized by permissions token") + } ~> + check { + handled shouldBe true + responseAs[String] shouldBe "Alright, user 1 is authorized by permissions token" + } + } +} diff --git a/core-rest/src/test/scala/xyz/driver/core/GeneratorsTest.scala b/core-rest/src/test/scala/xyz/driver/core/GeneratorsTest.scala new file mode 100644 index 0000000..7e740a4 --- /dev/null +++ b/core-rest/src/test/scala/xyz/driver/core/GeneratorsTest.scala @@ -0,0 +1,264 @@ +package xyz.driver.core + +import org.scalatest.{Assertions, FlatSpec, Matchers} + +import scala.collection.immutable.IndexedSeq + +class GeneratorsTest extends FlatSpec with Matchers with Assertions { + import generators._ + + "Generators" should "be able to generate com.drivergrp.core.Id identifiers" in { + + val generatedId1 = nextId[String]() + val generatedId2 = nextId[String]() + val generatedId3 = nextId[Long]() + + generatedId1.length should be >= 0 + generatedId2.length should be >= 0 + generatedId3.length should be >= 0 + generatedId1 should not be generatedId2 + generatedId2 should !==(generatedId3) + } + + it should "be able to generate com.drivergrp.core.Id identifiers with max value" in { + + val generatedLimitedId1 = nextId[String](5) + val generatedLimitedId2 = nextId[String](4) + val generatedLimitedId3 = nextId[Long](3) + + generatedLimitedId1.length should be >= 0 + generatedLimitedId1.length should be < 6 + generatedLimitedId2.length should be >= 0 + generatedLimitedId2.length should be < 5 + generatedLimitedId3.length should be >= 0 + generatedLimitedId3.length should be < 4 + generatedLimitedId1 should not be generatedLimitedId2 + generatedLimitedId2 should !==(generatedLimitedId3) + } + + it should "be able to generate com.drivergrp.core.Name names" in { + + Seq.fill(10)(nextName[String]()).distinct.size should be > 1 + nextName[String]().value.length should be >= 0 + + val fixedLengthName = nextName[String](10) + fixedLengthName.length should be <= 10 + assert(!fixedLengthName.value.exists(_.isControl)) + } + + it should "be able to generate com.drivergrp.core.NonEmptyName with non empty strings" in { + + assert(nextNonEmptyName[String]().value.value.nonEmpty) + } + + it should "be able to generate proper UUIDs" in { + + nextUuid() should not be nextUuid() + nextUuid().toString.length should be(36) + } + + it should "be able to generate new Revisions" in { + + nextRevision[String]() should not be nextRevision[String]() + nextRevision[String]().id.length should be > 0 + } + + it should "be able to generate strings" in { + + nextString() should not be nextString() + nextString().length should be >= 0 + + val fixedLengthString = nextString(20) + fixedLengthString.length should be <= 20 + assert(!fixedLengthString.exists(_.isControl)) + } + + it should "be able to generate strings non-empty strings whic are non empty" in { + + assert(nextNonEmptyString().value.nonEmpty) + } + + it should "be able to generate options which are sometimes have values and sometimes not" in { + + val generatedOption = nextOption("2") + + generatedOption should not contain "1" + assert(generatedOption === Some("2") || generatedOption === None) + } + + it should "be able to generate a pair of two generated values" in { + + val constantPair = nextPair("foo", 1L) + constantPair._1 should be("foo") + constantPair._2 should be(1L) + + val generatedPair = nextPair(nextId[Int](), nextName[Int]()) + + generatedPair._1.length should be > 0 + generatedPair._2.length should be > 0 + + nextPair(nextId[Int](), nextName[Int]()) should not be + nextPair(nextId[Int](), nextName[Int]()) + } + + it should "be able to generate a triad of two generated values" in { + + val constantTriad = nextTriad("foo", "bar", 1L) + constantTriad._1 should be("foo") + constantTriad._2 should be("bar") + constantTriad._3 should be(1L) + + val generatedTriad = nextTriad(nextId[Int](), nextName[Int](), nextBigDecimal()) + + generatedTriad._1.length should be > 0 + generatedTriad._2.length should be > 0 + generatedTriad._3 should be >= BigDecimal(0.00) + + nextTriad(nextId[Int](), nextName[Int](), nextBigDecimal()) should not be + nextTriad(nextId[Int](), nextName[Int](), nextBigDecimal()) + } + + it should "be able to generate a time value" in { + + val generatedTime = nextTime() + val currentTime = System.currentTimeMillis() + + generatedTime.millis should be >= 0L + generatedTime.millis should be <= currentTime + } + + it should "be able to generate a time range value" in { + + val generatedTimeRange = nextTimeRange() + val currentTime = System.currentTimeMillis() + + generatedTimeRange.start.millis should be >= 0L + generatedTimeRange.start.millis should be <= currentTime + generatedTimeRange.end.millis should be >= 0L + generatedTimeRange.end.millis should be <= currentTime + generatedTimeRange.start.millis should be <= generatedTimeRange.end.millis + } + + it should "be able to generate a BigDecimal value" in { + + val defaultGeneratedBigDecimal = nextBigDecimal() + + defaultGeneratedBigDecimal should be >= BigDecimal(0.00) + defaultGeneratedBigDecimal should be <= BigDecimal(1000000.00) + defaultGeneratedBigDecimal.precision should be(2) + + val unitIntervalBigDecimal = nextBigDecimal(1.00, 8) + + unitIntervalBigDecimal should be >= BigDecimal(0.00) + unitIntervalBigDecimal should be <= BigDecimal(1.00) + unitIntervalBigDecimal.precision should be(8) + } + + it should "be able to generate a specific value from a set of values" in { + + val possibleOptions = Set(1, 3, 5, 123, 0, 9) + + val pick1 = generators.oneOf(possibleOptions) + val pick2 = generators.oneOf(possibleOptions) + val pick3 = generators.oneOf(possibleOptions) + + possibleOptions should contain(pick1) + possibleOptions should contain(pick2) + possibleOptions should contain(pick3) + + val pick4 = generators.oneOf(1, 3, 5, 123, 0, 9) + val pick5 = generators.oneOf(1, 3, 5, 123, 0, 9) + val pick6 = generators.oneOf(1, 3, 5, 123, 0, 9) + + possibleOptions should contain(pick4) + possibleOptions should contain(pick5) + possibleOptions should contain(pick6) + + Set(pick1, pick2, pick3, pick4, pick5, pick6).size should be >= 1 + } + + it should "be able to generate a specific value from an enumeratum enum" in { + + import enumeratum._ + sealed trait TestEnumValue extends EnumEntry + object TestEnum extends Enum[TestEnumValue] { + case object Value1 extends TestEnumValue + case object Value2 extends TestEnumValue + case object Value3 extends TestEnumValue + case object Value4 extends TestEnumValue + val values: IndexedSeq[TestEnumValue] = findValues + } + + val picks = (1 to 100).map(_ => generators.oneOf(TestEnum)) + + TestEnum.values should contain allElementsOf picks + picks.toSet.size should be >= 1 + } + + it should "be able to generate array with values generated by generators" in { + + val arrayOfTimes = arrayOf(nextTime(), 16) + arrayOfTimes.length should be <= 16 + + val arrayOfBigDecimals = arrayOf(nextBigDecimal(), 8) + arrayOfBigDecimals.length should be <= 8 + } + + it should "be able to generate seq with values generated by generators" in { + + val seqOfTimes = seqOf(nextTime(), 16) + seqOfTimes.size should be <= 16 + + val seqOfBigDecimals = seqOf(nextBigDecimal(), 8) + seqOfBigDecimals.size should be <= 8 + } + + it should "be able to generate vector with values generated by generators" in { + + val vectorOfTimes = vectorOf(nextTime(), 16) + vectorOfTimes.size should be <= 16 + + val vectorOfStrings = seqOf(nextString(), 8) + vectorOfStrings.size should be <= 8 + } + + it should "be able to generate list with values generated by generators" in { + + val listOfTimes = listOf(nextTime(), 16) + listOfTimes.size should be <= 16 + + val listOfBigDecimals = seqOf(nextBigDecimal(), 8) + listOfBigDecimals.size should be <= 8 + } + + it should "be able to generate set with values generated by generators" in { + + val setOfTimes = vectorOf(nextTime(), 16) + setOfTimes.size should be <= 16 + + val setOfBigDecimals = seqOf(nextBigDecimal(), 8) + setOfBigDecimals.size should be <= 8 + } + + it should "be able to generate maps with keys and values generated by generators" in { + + val generatedConstantMap = mapOf("key", 123, 10) + generatedConstantMap.size should be <= 1 + assert(generatedConstantMap.keys.forall(_ == "key")) + assert(generatedConstantMap.values.forall(_ == 123)) + + val generatedMap = mapOf(nextString(10), nextBigDecimal(), 10) + assert(generatedMap.keys.forall(_.length <= 10)) + assert(generatedMap.values.forall(_ >= BigDecimal(0.00))) + } + + it should "compose deeply" in { + + val generatedNestedMap = mapOf(nextString(10), nextPair(nextBigDecimal(), nextOption(123)), 10) + + generatedNestedMap.size should be <= 10 + generatedNestedMap.keySet.size should be <= 10 + generatedNestedMap.values.size should be <= 10 + assert(generatedNestedMap.values.forall(value => !value._2.exists(_ != 123))) + } +} diff --git a/core-rest/src/test/scala/xyz/driver/core/JsonTest.scala b/core-rest/src/test/scala/xyz/driver/core/JsonTest.scala new file mode 100644 index 0000000..fd693f9 --- /dev/null +++ b/core-rest/src/test/scala/xyz/driver/core/JsonTest.scala @@ -0,0 +1,521 @@ +package xyz.driver.core + +import java.net.InetAddress +import java.time.{Instant, LocalDate} + +import akka.http.scaladsl.model.Uri +import akka.http.scaladsl.server.PathMatcher +import akka.http.scaladsl.server.PathMatcher.Matched +import com.neovisionaries.i18n.{CountryCode, CurrencyCode} +import enumeratum._ +import eu.timepit.refined.collection.NonEmpty +import eu.timepit.refined.numeric.Positive +import eu.timepit.refined.refineMV +import org.scalatest.{Inspectors, Matchers, WordSpec} +import spray.json._ +import xyz.driver.core.TestTypes.CustomGADT +import xyz.driver.core.auth.AuthCredentials +import xyz.driver.core.domain.{Email, PhoneNumber} +import xyz.driver.core.json._ +import xyz.driver.core.json.enumeratum.HasJsonFormat +import xyz.driver.core.tagging._ +import xyz.driver.core.time.provider.SystemTimeProvider +import xyz.driver.core.time.{Time, TimeOfDay} + +import scala.collection.immutable.IndexedSeq +import scala.language.postfixOps + +class JsonTest extends WordSpec with Matchers with Inspectors { + import DefaultJsonProtocol._ + + "Json format for Id" should { + "read and write correct JSON" in { + + val referenceId = Id[String]("1312-34A") + + val writtenJson = json.idFormat.write(referenceId) + writtenJson.prettyPrint should be("\"1312-34A\"") + + val parsedId = json.idFormat.read(writtenJson) + parsedId should be(referenceId) + } + } + + "Json format for @@" should { + "read and write correct JSON" in { + trait Irrelevant + val reference = Id[JsonTest]("SomeID").tagged[Irrelevant] + + val format = json.taggedFormat[Id[JsonTest], Irrelevant] + + val writtenJson = format.write(reference) + writtenJson shouldBe JsString("SomeID") + + val parsedId: Id[JsonTest] @@ Irrelevant = format.read(writtenJson) + parsedId shouldBe reference + } + + "read and write correct JSON when there's an implicit conversion defined" in { + val input = " some string " + + JsString(input).convertTo[String @@ Trimmed] shouldBe input.trim() + + val trimmed: String @@ Trimmed = input + trimmed.toJson shouldBe JsString(trimmed) + } + } + + "Json format for Name" should { + "read and write correct JSON" in { + + val referenceName = Name[String]("Homer") + + val writtenJson = json.nameFormat.write(referenceName) + writtenJson.prettyPrint should be("\"Homer\"") + + val parsedName = json.nameFormat.read(writtenJson) + parsedName should be(referenceName) + } + + "read and write correct JSON for Name @@ Trimmed" in { + trait Irrelevant + JsString(" some name ").convertTo[Name[Irrelevant] @@ Trimmed] shouldBe Name[Irrelevant]("some name") + + val trimmed: Name[Irrelevant] @@ Trimmed = Name(" some name ") + trimmed.toJson shouldBe JsString("some name") + } + } + + "Json format for NonEmptyName" should { + "read and write correct JSON" in { + + val jsonFormat = json.nonEmptyNameFormat[String] + + val referenceNonEmptyName = NonEmptyName[String](refineMV[NonEmpty]("Homer")) + + val writtenJson = jsonFormat.write(referenceNonEmptyName) + writtenJson.prettyPrint should be("\"Homer\"") + + val parsedNonEmptyName = jsonFormat.read(writtenJson) + parsedNonEmptyName should be(referenceNonEmptyName) + } + } + + "Json format for Time" should { + "read and write correct JSON" in { + + val referenceTime = new SystemTimeProvider().currentTime() + + val writtenJson = json.timeFormat.write(referenceTime) + writtenJson.prettyPrint should be("{\n \"timestamp\": " + referenceTime.millis + "\n}") + + val parsedTime = json.timeFormat.read(writtenJson) + parsedTime should be(referenceTime) + } + + "read from inputs compatible with Instant" in { + val referenceTime = new SystemTimeProvider().currentTime() + + val jsons = Seq(JsNumber(referenceTime.millis), JsString(Instant.ofEpochMilli(referenceTime.millis).toString)) + + forAll(jsons) { json => + json.convertTo[Time] shouldBe referenceTime + } + } + } + + "Json format for TimeOfDay" should { + "read and write correct JSON" in { + val utcTimeZone = java.util.TimeZone.getTimeZone("UTC") + val referenceTimeOfDay = TimeOfDay.parseTimeString(utcTimeZone)("08:00:00") + val writtenJson = json.timeOfDayFormat.write(referenceTimeOfDay) + writtenJson should be("""{"localTime":"08:00:00","timeZone":"UTC"}""".parseJson) + val parsed = json.timeOfDayFormat.read(writtenJson) + parsed should be(referenceTimeOfDay) + } + } + + "Json format for Date" should { + "read and write correct JSON" in { + import date._ + + val referenceDate = Date(1941, Month.DECEMBER, 7) + + val writtenJson = json.dateFormat.write(referenceDate) + writtenJson.prettyPrint should be("\"1941-12-07\"") + + val parsedDate = json.dateFormat.read(writtenJson) + parsedDate should be(referenceDate) + } + } + + "Json format for java.time.Instant" should { + + val isoString = "2018-08-08T08:08:08.888Z" + val instant = Instant.parse(isoString) + + "read correct JSON when value is an epoch milli number" in { + JsNumber(instant.toEpochMilli).convertTo[Instant] shouldBe instant + } + + "read correct JSON when value is an ISO timestamp string" in { + JsString(isoString).convertTo[Instant] shouldBe instant + } + + "read correct JSON when value is an object with nested 'timestamp'/millis field" in { + val json = JsObject( + "timestamp" -> JsNumber(instant.toEpochMilli) + ) + + json.convertTo[Instant] shouldBe instant + } + + "write correct JSON" in { + instant.toJson shouldBe JsString(isoString) + } + } + + "Path matcher for Instant" should { + + val isoString = "2018-08-08T08:08:08.888Z" + val instant = Instant.parse(isoString) + + val matcher = PathMatcher("foo") / InstantInPath / + + "read instant from millis" in { + matcher(Uri.Path("foo") / ("+" + instant.toEpochMilli) / "bar") shouldBe Matched(Uri.Path("bar"), Tuple1(instant)) + } + + "read instant from ISO timestamp string" in { + matcher(Uri.Path("foo") / isoString / "bar") shouldBe Matched(Uri.Path("bar"), Tuple1(instant)) + } + } + + "Json format for java.time.LocalDate" should { + + "read and write correct JSON" in { + val dateString = "2018-08-08" + val date = LocalDate.parse(dateString) + + date.toJson shouldBe JsString(dateString) + JsString(dateString).convertTo[LocalDate] shouldBe date + } + } + + "Json format for Revision" should { + "read and write correct JSON" in { + + val referenceRevision = Revision[String]("037e2ec0-8901-44ac-8e53-6d39f6479db4") + + val writtenJson = json.revisionFormat.write(referenceRevision) + writtenJson.prettyPrint should be("\"" + referenceRevision.id + "\"") + + val parsedRevision = json.revisionFormat.read(writtenJson) + parsedRevision should be(referenceRevision) + } + } + + "Json format for Email" should { + "read and write correct JSON" in { + + val referenceEmail = Email("test", "drivergrp.com") + + val writtenJson = json.emailFormat.write(referenceEmail) + writtenJson should be("\"test@drivergrp.com\"".parseJson) + + val parsedEmail = json.emailFormat.read(writtenJson) + parsedEmail should be(referenceEmail) + } + } + + "Json format for PhoneNumber" should { + "read and write correct JSON" in { + + val referencePhoneNumber = PhoneNumber("1", "4243039608") + + val writtenJson = json.phoneNumberFormat.write(referencePhoneNumber) + writtenJson should be("""{"countryCode":"1","number":"4243039608"}""".parseJson) + + val parsedPhoneNumber = json.phoneNumberFormat.read(writtenJson) + parsedPhoneNumber should be(referencePhoneNumber) + } + + "reject an invalid phone number" in { + val phoneJson = """{"countryCode":"1","number":"111-111-1113"}""".parseJson + + intercept[DeserializationException] { + 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 { + "read and write correct JSON" in { + + sealed trait EnumVal + case object Val1 extends EnumVal + case object Val2 extends EnumVal + case object Val3 extends EnumVal + + val format = new EnumJsonFormat[EnumVal]("a" -> Val1, "b" -> Val2, "c" -> Val3) + + val referenceEnumValue1 = Val2 + val referenceEnumValue2 = Val3 + + val writtenJson1 = format.write(referenceEnumValue1) + writtenJson1.prettyPrint should be("\"b\"") + + val writtenJson2 = format.write(referenceEnumValue2) + writtenJson2.prettyPrint should be("\"c\"") + + val parsedEnumValue1 = format.read(writtenJson1) + val parsedEnumValue2 = format.read(writtenJson2) + + parsedEnumValue1 should be(referenceEnumValue1) + parsedEnumValue2 should be(referenceEnumValue2) + } + } + + "Json format for Enums (external)" should { + "read and write correct JSON" in { + + sealed trait MyEnum extends EnumEntry + object MyEnum extends Enum[MyEnum] { + case object Val1 extends MyEnum + case object `Val 2` extends MyEnum + case object `Val/3` extends MyEnum + + val values: IndexedSeq[MyEnum] = findValues + } + + val format = new enumeratum.EnumJsonFormat(MyEnum) + + val referenceEnumValue1 = MyEnum.`Val 2` + val referenceEnumValue2 = MyEnum.`Val/3` + + val writtenJson1 = format.write(referenceEnumValue1) + writtenJson1 shouldBe JsString("Val 2") + + val writtenJson2 = format.write(referenceEnumValue2) + writtenJson2 shouldBe JsString("Val/3") + + val parsedEnumValue1 = format.read(writtenJson1) + val parsedEnumValue2 = format.read(writtenJson2) + + parsedEnumValue1 shouldBe referenceEnumValue1 + parsedEnumValue2 shouldBe referenceEnumValue2 + + intercept[DeserializationException] { + format.read(JsString("Val4")) + }.getMessage shouldBe "Unexpected value Val4. Expected one of: [Val1, Val 2, Val/3]" + } + } + + "Json format for Enums (automatic)" should { + "read and write correct JSON and not require import" in { + + sealed trait MyEnum extends EnumEntry + object MyEnum extends Enum[MyEnum] with HasJsonFormat[MyEnum] { + case object Val1 extends MyEnum + case object `Val 2` extends MyEnum + case object `Val/3` extends MyEnum + + val values: IndexedSeq[MyEnum] = findValues + } + + val referenceEnumValue1: MyEnum = MyEnum.`Val 2` + val referenceEnumValue2: MyEnum = MyEnum.`Val/3` + + val writtenJson1 = referenceEnumValue1.toJson + writtenJson1 shouldBe JsString("Val 2") + + val writtenJson2 = referenceEnumValue2.toJson + writtenJson2 shouldBe JsString("Val/3") + + import spray.json._ + + val parsedEnumValue1 = writtenJson1.prettyPrint.parseJson.convertTo[MyEnum] + val parsedEnumValue2 = writtenJson2.prettyPrint.parseJson.convertTo[MyEnum] + + parsedEnumValue1 should be(referenceEnumValue1) + parsedEnumValue2 should be(referenceEnumValue2) + + intercept[DeserializationException] { + JsString("Val4").convertTo[MyEnum] + }.getMessage shouldBe "Unexpected value Val4. Expected one of: [Val1, Val 2, Val/3]" + } + } + + // Should be defined outside of case to have a TypeTag + case class CustomWrapperClass(value: Int) + + "Json format for Value classes" should { + "read and write correct JSON" in { + + val format = new ValueClassFormat[CustomWrapperClass](v => BigDecimal(v.value), d => CustomWrapperClass(d.toInt)) + + val referenceValue1 = CustomWrapperClass(-2) + val referenceValue2 = CustomWrapperClass(10) + + val writtenJson1 = format.write(referenceValue1) + writtenJson1.prettyPrint should be("-2") + + val writtenJson2 = format.write(referenceValue2) + writtenJson2.prettyPrint should be("10") + + val parsedValue1 = format.read(writtenJson1) + val parsedValue2 = format.read(writtenJson2) + + parsedValue1 should be(referenceValue1) + parsedValue2 should be(referenceValue2) + } + } + + "Json format for classes GADT" should { + "read and write correct JSON" in { + + import CustomGADT._ + import DefaultJsonProtocol._ + implicit val case1Format = jsonFormat1(GadtCase1) + implicit val case2Format = jsonFormat1(GadtCase2) + implicit val case3Format = jsonFormat1(GadtCase3) + + val format = GadtJsonFormat.create[CustomGADT]("gadtTypeField") { + case _: CustomGADT.GadtCase1 => "case1" + case _: CustomGADT.GadtCase2 => "case2" + case _: CustomGADT.GadtCase3 => "case3" + } { + case "case1" => case1Format + case "case2" => case2Format + case "case3" => case3Format + } + + val referenceValue1 = CustomGADT.GadtCase1("4") + val referenceValue2 = CustomGADT.GadtCase2("Hi!") + + val writtenJson1 = format.write(referenceValue1) + writtenJson1 should be("{\n \"field\": \"4\",\n\"gadtTypeField\": \"case1\"\n}".parseJson) + + val writtenJson2 = format.write(referenceValue2) + writtenJson2 should be("{\"field\":\"Hi!\",\"gadtTypeField\":\"case2\"}".parseJson) + + val parsedValue1 = format.read(writtenJson1) + val parsedValue2 = format.read(writtenJson2) + + parsedValue1 should be(referenceValue1) + parsedValue2 should be(referenceValue2) + } + } + + "Json format for a Refined value" should { + "read and write correct JSON" in { + + val jsonFormat = json.refinedJsonFormat[Int, Positive] + + val referenceRefinedNumber = refineMV[Positive](42) + + val writtenJson = jsonFormat.write(referenceRefinedNumber) + writtenJson should be("42".parseJson) + + val parsedRefinedNumber = jsonFormat.read(writtenJson) + parsedRefinedNumber should be(referenceRefinedNumber) + } + } + + "InetAddress format" should { + "read and write correct JSON" in { + val address = InetAddress.getByName("127.0.0.1") + val json = inetAddressFormat.write(address) + + json shouldBe JsString("127.0.0.1") + + val parsed = inetAddressFormat.read(json) + parsed shouldBe address + } + + "throw a DeserializationException for an invalid IP Address" in { + assertThrows[DeserializationException] { + val invalidAddress = JsString("foobar:") + inetAddressFormat.read(invalidAddress) + } + } + } + + "AuthCredentials format" should { + "read and write correct JSON" in { + val email = Email("someone", "noehere.com") + val phoneId = PhoneNumber.parse("1 207 8675309") + val password = "nopassword" + + phoneId.isDefined should be(true) // test this real quick + + val emailAuth = AuthCredentials(email.toString, password) + val pnAuth = AuthCredentials(phoneId.get.toString, password) + + val emailWritten = authCredentialsFormat.write(emailAuth) + emailWritten should be("""{"identifier":"someone@noehere.com","password":"nopassword"}""".parseJson) + + val phoneWritten = authCredentialsFormat.write(pnAuth) + phoneWritten should be("""{"identifier":"+1 2078675309","password":"nopassword"}""".parseJson) + + val identifierEmailParsed = + authCredentialsFormat.read("""{"identifier":"someone@nowhere.com","password":"nopassword"}""".parseJson) + var written = authCredentialsFormat.write(identifierEmailParsed) + written should be("{\"identifier\":\"someone@nowhere.com\",\"password\":\"nopassword\"}".parseJson) + + val emailEmailParsed = + authCredentialsFormat.read("""{"email":"someone@nowhere.com","password":"nopassword"}""".parseJson) + written = authCredentialsFormat.write(emailEmailParsed) + written should be("{\"identifier\":\"someone@nowhere.com\",\"password\":\"nopassword\"}".parseJson) + + } + } + + "CountryCode format" should { + "read and write correct JSON" in { + val samples = Seq( + "US" -> CountryCode.US, + "CN" -> CountryCode.CN, + "AT" -> CountryCode.AT + ) + + forAll(samples) { + case (serialized, enumValue) => + countryCodeFormat.write(enumValue) shouldBe JsString(serialized) + countryCodeFormat.read(JsString(serialized)) shouldBe enumValue + } + } + } + + "CurrencyCode format" should { + "read and write correct JSON" in { + val samples = Seq( + "USD" -> CurrencyCode.USD, + "CNY" -> CurrencyCode.CNY, + "EUR" -> CurrencyCode.EUR + ) + + forAll(samples) { + case (serialized, enumValue) => + currencyCodeFormat.write(enumValue) shouldBe JsString(serialized) + currencyCodeFormat.read(JsString(serialized)) shouldBe enumValue + } + } + } + +} diff --git a/core-rest/src/test/scala/xyz/driver/core/TestTypes.scala b/core-rest/src/test/scala/xyz/driver/core/TestTypes.scala new file mode 100644 index 0000000..bb25deb --- /dev/null +++ b/core-rest/src/test/scala/xyz/driver/core/TestTypes.scala @@ -0,0 +1,14 @@ +package xyz.driver.core + +object TestTypes { + + sealed trait CustomGADT { + val field: String + } + + object CustomGADT { + final case class GadtCase1(field: String) extends CustomGADT + final case class GadtCase2(field: String) extends CustomGADT + final case class GadtCase3(field: String) extends CustomGADT + } +} diff --git a/core-rest/src/test/scala/xyz/driver/core/rest/DriverAppTest.scala b/core-rest/src/test/scala/xyz/driver/core/rest/DriverAppTest.scala new file mode 100644 index 0000000..324c8d8 --- /dev/null +++ b/core-rest/src/test/scala/xyz/driver/core/rest/DriverAppTest.scala @@ -0,0 +1,89 @@ +package xyz.driver.core.rest + +import akka.http.scaladsl.model.headers._ +import akka.http.scaladsl.model.{HttpMethod, StatusCodes} +import akka.http.scaladsl.server.{Directives, Route} +import akka.http.scaladsl.testkit.ScalatestRouteTest +import com.typesafe.config.ConfigFactory +import org.scalatest.{AsyncFlatSpec, Matchers} +import xyz.driver.core.app.{DriverApp, SimpleModule} + +class DriverAppTest extends AsyncFlatSpec with ScalatestRouteTest with Matchers with Directives { + val config = ConfigFactory.parseString(""" + |application { + | cors { + | allowedOrigins: ["example.com"] + | } + |} + """.stripMargin).withFallback(ConfigFactory.load) + + val origin = Origin(HttpOrigin("https", Host("example.com"))) + val allowedOrigins = Set(HttpOrigin("https", Host("example.com"))) + val allowedMethods: collection.immutable.Seq[HttpMethod] = { + import akka.http.scaladsl.model.HttpMethods._ + collection.immutable.Seq(GET, PUT, POST, PATCH, DELETE, OPTIONS, TRACE) + } + + import scala.reflect.runtime.universe.typeOf + class TestApp(testRoute: Route) + extends DriverApp( + appName = "test-app", + version = "0.0.1", + gitHash = "deadb33f", + modules = Seq(new SimpleModule("test-module", theRoute = testRoute, routeType = typeOf[DriverApp])), + config = config, + log = xyz.driver.core.logging.NoLogger + ) + + it should "respond with the correct CORS headers for the swagger OPTIONS route" in { + val route = new TestApp(get(complete(StatusCodes.OK))) + Options(s"/api-docs/swagger.json").withHeaders(origin) ~> route.appRoute ~> check { + status shouldBe StatusCodes.OK + headers should contain(`Access-Control-Allow-Origin`(HttpOriginRange(allowedOrigins.toSeq: _*))) + header[`Access-Control-Allow-Methods`].get.methods should contain theSameElementsAs allowedMethods + } + } + + it should "respond with the correct CORS headers for the test route" in { + val route = new TestApp(get(complete(StatusCodes.OK))) + Get(s"/api/v1/test").withHeaders(origin) ~> route.appRoute ~> check { + status shouldBe StatusCodes.OK + headers should contain(`Access-Control-Allow-Origin`(HttpOriginRange(allowedOrigins.toSeq: _*))) + } + } + + it should "respond with the correct CORS headers for a concatenated route" in { + val route = new TestApp(get(complete(StatusCodes.OK)) ~ post(complete(StatusCodes.OK))) + Post(s"/api/v1/test").withHeaders(origin) ~> route.appRoute ~> check { + status shouldBe StatusCodes.OK + headers should contain(`Access-Control-Allow-Origin`(HttpOriginRange(allowedOrigins.toSeq: _*))) + } + } + + it should "allow subdomains of allowed origin suffixes" in { + val route = new TestApp(get(complete(StatusCodes.OK))) + Get(s"/api/v1/test") + .withHeaders(Origin(HttpOrigin("https", Host("foo.example.com")))) ~> route.appRoute ~> check { + status shouldBe StatusCodes.OK + headers should contain(`Access-Control-Allow-Origin`(HttpOrigin("https", Host("foo.example.com")))) + } + } + + it should "respond with default domains for invalid origins" in { + val route = new TestApp(get(complete(StatusCodes.OK))) + Get(s"/api/v1/test") + .withHeaders(Origin(HttpOrigin("https", Host("invalid.foo.bar.com")))) ~> route.appRoute ~> check { + status shouldBe StatusCodes.OK + headers should contain(`Access-Control-Allow-Origin`(HttpOriginRange.*)) + } + } + + it should "respond with Pragma and Cache-Control (no-cache) headers" in { + val route = new TestApp(get(complete(StatusCodes.OK))) + Get(s"/api/v1/test") ~> route.appRoute ~> check { + status shouldBe StatusCodes.OK + header("Pragma").map(_.value()) should contain("no-cache") + header[`Cache-Control`].map(_.value()) should contain("no-cache") + } + } +} diff --git a/core-rest/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala b/core-rest/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala new file mode 100644 index 0000000..cc0019a --- /dev/null +++ b/core-rest/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala @@ -0,0 +1,121 @@ +package xyz.driver.core.rest + +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport +import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.model.headers.Connection +import akka.http.scaladsl.server.Directives.{complete => akkaComplete} +import akka.http.scaladsl.server.{Directives, RejectionHandler, Route} +import akka.http.scaladsl.testkit.ScalatestRouteTest +import com.typesafe.scalalogging.Logger +import org.scalatest.{AsyncFlatSpec, Matchers} +import xyz.driver.core.json.serviceExceptionFormat +import xyz.driver.core.logging.NoLogger +import xyz.driver.core.rest.errors._ + +import scala.concurrent.Future + +class DriverRouteTest + extends AsyncFlatSpec with ScalatestRouteTest with SprayJsonSupport with Matchers with Directives { + class TestRoute(override val route: Route) extends DriverRoute { + override def log: Logger = NoLogger + } + + "DriverRoute" should "respond with 200 OK for a basic route" in { + val route = new TestRoute(akkaComplete(StatusCodes.OK)) + + Get("/api/v1/foo/bar") ~> route.routeWithDefaults ~> check { + handled shouldBe true + status shouldBe StatusCodes.OK + } + } + + it should "respond with a 401 for an InvalidInputException" in { + val route = new TestRoute(akkaComplete(Future.failed[String](InvalidInputException()))) + + Post("/api/v1/foo/bar") ~> route.routeWithDefaults ~> check { + handled shouldBe true + status shouldBe StatusCodes.BadRequest + responseAs[ServiceException] shouldBe InvalidInputException() + } + } + + it should "respond with a 403 for InvalidActionException" in { + val route = new TestRoute(akkaComplete(Future.failed[String](InvalidActionException()))) + + Post("/api/v1/foo/bar") ~> route.routeWithDefaults ~> check { + handled shouldBe true + status shouldBe StatusCodes.Forbidden + responseAs[ServiceException] shouldBe InvalidActionException() + } + } + + it should "respond with a 404 for ResourceNotFoundException" in { + val route = new TestRoute(akkaComplete(Future.failed[String](ResourceNotFoundException()))) + + Post("/api/v1/foo/bar") ~> route.routeWithDefaults ~> check { + handled shouldBe true + status shouldBe StatusCodes.NotFound + responseAs[ServiceException] shouldBe ResourceNotFoundException() + } + } + + it should "respond with a 500 for ExternalServiceException" in { + val error = ExternalServiceException("GET /api/v1/users/", "Permission denied", None) + val route = new TestRoute(akkaComplete(Future.failed[String](error))) + + Post("/api/v1/foo/bar") ~> route.routeWithDefaults ~> check { + handled shouldBe true + status shouldBe StatusCodes.InternalServerError + responseAs[ServiceException] shouldBe error + } + } + + it should "allow pass-through of external service exceptions" in { + val innerError = InvalidInputException() + val error = ExternalServiceException("GET /api/v1/users/", "Permission denied", Some(innerError)) + val future = Future.failed[String](error) + val route = new TestRoute(akkaComplete(future.passThroughExternalServiceException)) + + Post("/api/v1/foo/bar") ~> route.routeWithDefaults ~> check { + handled shouldBe true + status shouldBe StatusCodes.BadRequest + responseAs[ServiceException] shouldBe innerError + } + } + + it should "respond with a 503 for ExternalServiceTimeoutException" in { + val error = ExternalServiceTimeoutException("GET /api/v1/users/") + val route = new TestRoute(akkaComplete(Future.failed[String](error))) + + Post("/api/v1/foo/bar") ~> route.routeWithDefaults ~> check { + handled shouldBe true + status shouldBe StatusCodes.GatewayTimeout + responseAs[ServiceException] shouldBe error + } + } + + it should "respond with a 500 for DatabaseException" in { + val route = new TestRoute(akkaComplete(Future.failed[String](DatabaseException()))) + + Post("/api/v1/foo/bar") ~> route.routeWithDefaults ~> check { + handled shouldBe true + status shouldBe StatusCodes.InternalServerError + responseAs[ServiceException] shouldBe DatabaseException() + } + } + + it should "add a `Connection: close` header to avoid clashing with envoy's timeouts" in { + val rejectionHandler = RejectionHandler.newBuilder().handleNotFound(complete(StatusCodes.NotFound)).result() + val route = new TestRoute(handleRejections(rejectionHandler)((get & path("foo"))(complete("OK")))) + + Get("/foo") ~> route.routeWithDefaults ~> check { + status shouldBe StatusCodes.OK + headers should contain(Connection("close")) + } + + Get("/bar") ~> route.routeWithDefaults ~> check { + status shouldBe StatusCodes.NotFound + headers should contain(Connection("close")) + } + } +} diff --git a/core-rest/src/test/scala/xyz/driver/core/rest/PatchDirectivesTest.scala b/core-rest/src/test/scala/xyz/driver/core/rest/PatchDirectivesTest.scala new file mode 100644 index 0000000..987717d --- /dev/null +++ b/core-rest/src/test/scala/xyz/driver/core/rest/PatchDirectivesTest.scala @@ -0,0 +1,101 @@ +package xyz.driver.core.rest + +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport +import akka.http.scaladsl.model._ +import akka.http.scaladsl.model.headers.`Content-Type` +import akka.http.scaladsl.server.{Directives, Route} +import akka.http.scaladsl.testkit.ScalatestRouteTest +import org.scalatest.{FlatSpec, Matchers} +import spray.json._ +import xyz.driver.core.{Id, Name} +import xyz.driver.core.json._ + +import scala.concurrent.Future + +class PatchDirectivesTest + extends FlatSpec with Matchers with ScalatestRouteTest with SprayJsonSupport with DefaultJsonProtocol + with Directives with PatchDirectives { + case class Bar(name: Name[Bar], size: Int) + case class Foo(id: Id[Foo], name: Name[Foo], rank: Int, bar: Option[Bar]) + implicit val barFormat: RootJsonFormat[Bar] = jsonFormat2(Bar) + implicit val fooFormat: RootJsonFormat[Foo] = jsonFormat4(Foo) + + val testFoo: Foo = Foo(Id("1"), Name(s"Foo"), 1, Some(Bar(Name("Bar"), 10))) + + def route(retrieve: => Future[Option[Foo]]): Route = + Route.seal(path("api" / "v1" / "foos" / IdInPath[Foo]) { fooId => + entity(as[Patchable[Foo]]) { fooPatchable => + mergePatch(fooPatchable, retrieve) { updatedFoo => + complete(updatedFoo) + } + } + }) + + val MergePatchContentType = ContentType(`application/merge-patch+json`) + val ContentTypeHeader = `Content-Type`(MergePatchContentType) + def jsonEntity(json: String, contentType: ContentType.NonBinary = MergePatchContentType): RequestEntity = + HttpEntity(contentType, json) + + "PatchSupport" should "allow partial updates to an existing object" in { + val fooRetrieve = Future.successful(Some(testFoo)) + + Patch("/api/v1/foos/1", jsonEntity("""{"rank": 4}""")) ~> route(fooRetrieve) ~> check { + handled shouldBe true + responseAs[Foo] shouldBe testFoo.copy(rank = 4) + } + } + + it should "merge deeply nested objects" in { + val fooRetrieve = Future.successful(Some(testFoo)) + + Patch("/api/v1/foos/1", jsonEntity("""{"rank": 4, "bar": {"name": "My Bar"}}""")) ~> route(fooRetrieve) ~> check { + handled shouldBe true + responseAs[Foo] shouldBe testFoo.copy(rank = 4, bar = Some(Bar(Name("My Bar"), 10))) + } + } + + it should "return a 404 if the object is not found" in { + val fooRetrieve = Future.successful(None) + + Patch("/api/v1/foos/1", jsonEntity("""{"rank": 4}""")) ~> route(fooRetrieve) ~> check { + handled shouldBe true + status shouldBe StatusCodes.NotFound + } + } + + it should "handle nulls on optional values correctly" in { + val fooRetrieve = Future.successful(Some(testFoo)) + + Patch("/api/v1/foos/1", jsonEntity("""{"bar": null}""")) ~> route(fooRetrieve) ~> check { + handled shouldBe true + responseAs[Foo] shouldBe testFoo.copy(bar = None) + } + } + + it should "handle optional values correctly when old value is null" in { + val fooRetrieve = Future.successful(Some(testFoo.copy(bar = None))) + + Patch("/api/v1/foos/1", jsonEntity("""{"bar": {"name": "My Bar","size":10}}""")) ~> route(fooRetrieve) ~> check { + handled shouldBe true + responseAs[Foo] shouldBe testFoo.copy(bar = Some(Bar(Name("My Bar"), 10))) + } + } + + it should "return a 400 for nulls on non-optional values" in { + val fooRetrieve = Future.successful(Some(testFoo)) + + Patch("/api/v1/foos/1", jsonEntity("""{"rank": null}""")) ~> route(fooRetrieve) ~> check { + handled shouldBe true + status shouldBe StatusCodes.BadRequest + } + } + + it should "return a 415 for incorrect Content-Type" in { + val fooRetrieve = Future.successful(Some(testFoo)) + + Patch("/api/v1/foos/1", jsonEntity("""{"rank": 4}""", ContentTypes.`application/json`)) ~> route(fooRetrieve) ~> check { + status shouldBe StatusCodes.UnsupportedMediaType + responseAs[String] should include("application/merge-patch+json") + } + } +} diff --git a/core-rest/src/test/scala/xyz/driver/core/rest/RestTest.scala b/core-rest/src/test/scala/xyz/driver/core/rest/RestTest.scala new file mode 100644 index 0000000..19e4ed1 --- /dev/null +++ b/core-rest/src/test/scala/xyz/driver/core/rest/RestTest.scala @@ -0,0 +1,151 @@ +package xyz.driver.core.rest + +import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.server.{Directives, Route, ValidationRejection} +import akka.http.scaladsl.testkit.ScalatestRouteTest +import akka.util.ByteString +import org.scalatest.{Matchers, WordSpec} +import xyz.driver.core.rest + +import scala.concurrent.Future +import scala.util.Random + +class RestTest extends WordSpec with Matchers with ScalatestRouteTest with Directives { + "`escapeScriptTags` function" should { + "escape script tags properly" in { + val dirtyString = " + complete(StatusCodes.OK -> s"${paginated.pageNumber},${paginated.pageSize}") + } + "accept a pagination" in { + Get("/?pageNumber=2&pageSize=42") ~> route ~> check { + assert(status == StatusCodes.OK) + assert(entityAs[String] == "2,42") + } + } + "provide a default pagination" in { + Get("/") ~> route ~> check { + assert(status == StatusCodes.OK) + assert(entityAs[String] == "1,100") + } + } + "provide default values for a partial pagination" in { + Get("/?pageSize=2") ~> route ~> check { + assert(status == StatusCodes.OK) + assert(entityAs[String] == "1,2") + } + } + "reject an invalid pagination" in { + Get("/?pageNumber=-1") ~> route ~> check { + assert(rejection.isInstanceOf[ValidationRejection]) + } + } + } + + "optional paginated directive" should { + val route: Route = rest.optionalPagination { paginated => + complete(StatusCodes.OK -> paginated.map(p => s"${p.pageNumber},${p.pageSize}").getOrElse("no pagination")) + } + "accept a pagination" in { + Get("/?pageNumber=2&pageSize=42") ~> route ~> check { + assert(status == StatusCodes.OK) + assert(entityAs[String] == "2,42") + } + } + "without pagination" in { + Get("/") ~> route ~> check { + assert(status == StatusCodes.OK) + assert(entityAs[String] == "no pagination") + } + } + "reject an invalid pagination" in { + Get("/?pageNumber=1") ~> route ~> check { + assert(rejection.isInstanceOf[ValidationRejection]) + } + } + } + + "completeWithPagination directive" when { + import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ + import spray.json.DefaultJsonProtocol._ + + val data = Seq.fill(103)(Random.alphanumeric.take(10).mkString) + val route: Route = + parameter('empty.as[Boolean] ? false) { isEmpty => + completeWithPagination[String] { + case Some(pagination) if isEmpty => + Future.successful(ListResponse(Seq(), 0, Some(pagination))) + case Some(pagination) => + val filtered = data.slice(pagination.offset, pagination.offset + pagination.pageSize) + Future.successful(ListResponse(filtered, data.size, Some(pagination))) + case None if isEmpty => Future.successful(ListResponse(Seq(), 0, None)) + case None => Future.successful(ListResponse(data, data.size, None)) + } + } + + "pagination is defined" should { + "return a response with pagination headers" in { + Get("/?pageNumber=2&pageSize=10") ~> route ~> check { + responseAs[Seq[String]] shouldBe data.slice(10, 20) + header(ContextHeaders.ResourceCount).map(_.value) should contain("103") + header(ContextHeaders.PageCount).map(_.value) should contain("11") + } + } + + "disallow pageSize <= 0" in { + Get("/?pageNumber=2&pageSize=0") ~> route ~> check { + rejection shouldBe a[ValidationRejection] + } + + Get("/?pageNumber=2&pageSize=-1") ~> route ~> check { + rejection shouldBe a[ValidationRejection] + } + } + + "disallow pageNumber <= 0" in { + Get("/?pageNumber=0&pageSize=10") ~> route ~> check { + rejection shouldBe a[ValidationRejection] + } + + Get("/?pageNumber=-1&pageSize=10") ~> route ~> check { + rejection shouldBe a[ValidationRejection] + } + } + + "return PageCount == 0 if returning an empty list" in { + Get("/?empty=true&pageNumber=2&pageSize=10") ~> route ~> check { + responseAs[Seq[String]] shouldBe empty + header(ContextHeaders.ResourceCount).map(_.value) should contain("0") + header(ContextHeaders.PageCount).map(_.value) should contain("0") + } + } + } + + "pagination is not defined" should { + "return a response with pagination headers and PageCount == 1" in { + Get("/") ~> route ~> check { + responseAs[Seq[String]] shouldBe data + header(ContextHeaders.ResourceCount).map(_.value) should contain("103") + header(ContextHeaders.PageCount).map(_.value) should contain("1") + } + } + + "return PageCount == 0 if returning an empty list" in { + Get("/?empty=true") ~> route ~> check { + responseAs[Seq[String]] shouldBe empty + header(ContextHeaders.ResourceCount).map(_.value) should contain("0") + header(ContextHeaders.PageCount).map(_.value) should contain("0") + } + } + } + } +} diff --git a/src/main/scala/xyz/driver/core/auth.scala b/src/main/scala/xyz/driver/core/auth.scala deleted file mode 100644 index 896bd89..0000000 --- a/src/main/scala/xyz/driver/core/auth.scala +++ /dev/null @@ -1,43 +0,0 @@ -package xyz.driver.core - -import xyz.driver.core.domain.Email -import xyz.driver.core.time.Time -import scalaz.Equal - -object auth { - - trait Permission - - final case class Role(id: Id[Role], name: Name[Role]) { - - def oneOf(roles: Role*): Boolean = roles.contains(this) - - def oneOf(roles: Set[Role]): Boolean = roles.contains(this) - } - - object Role { - implicit def idEqual: Equal[Role] = Equal.equal[Role](_ == _) - } - - trait User { - def id: Id[User] - } - - final case class AuthToken(value: String) - - final case class AuthTokenUserInfo( - id: Id[User], - email: Email, - emailVerified: Boolean, - audience: String, - roles: Set[Role], - expirationTime: Time) - extends User - - final case class RefreshToken(value: String) - final case class PermissionsToken(value: String) - - final case class PasswordHash(value: String) - - final case class AuthCredentials(identifier: String, password: String) -} diff --git a/src/main/scala/xyz/driver/core/generators.scala b/src/main/scala/xyz/driver/core/generators.scala deleted file mode 100644 index d00b6dd..0000000 --- a/src/main/scala/xyz/driver/core/generators.scala +++ /dev/null @@ -1,143 +0,0 @@ -package xyz.driver.core - -import enumeratum._ -import java.math.MathContext -import java.time.{Instant, LocalDate, ZoneOffset} -import java.util.UUID - -import xyz.driver.core.time.{Time, TimeOfDay, TimeRange} -import xyz.driver.core.date.{Date, DayOfWeek} - -import scala.reflect.ClassTag -import scala.util.Random -import eu.timepit.refined.refineV -import eu.timepit.refined.api.Refined -import eu.timepit.refined.collection._ - -object generators { - - private val random = new Random - import random._ - private val secureRandom = new java.security.SecureRandom() - - private val DefaultMaxLength = 10 - private val StringLetters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ ".toSet - private val NonAmbigiousCharacters = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" - private val Numbers = "0123456789" - - private def nextTokenString(length: Int, chars: IndexedSeq[Char]): String = { - val builder = new StringBuilder - for (_ <- 0 until length) { - builder += chars(secureRandom.nextInt(chars.length)) - } - builder.result() - } - - /** Creates a random invitation token. - * - * This token is meant fo human input and avoids using ambiguous characters such as 'O' and '0'. It - * therefore contains less entropy and is not meant to be used as a cryptographic secret. */ - @deprecated( - "The term 'token' is too generic and security and readability conventions are not well defined. " + - "Services should implement their own version that suits their security requirements.", - "1.11.0" - ) - def nextToken(length: Int): String = nextTokenString(length, NonAmbigiousCharacters) - - @deprecated( - "The term 'token' is too generic and security and readability conventions are not well defined. " + - "Services should implement their own version that suits their security requirements.", - "1.11.0" - ) - def nextNumericToken(length: Int): String = nextTokenString(length, Numbers) - - def nextInt(maxValue: Int, minValue: Int = 0): Int = random.nextInt(maxValue - minValue) + minValue - - def nextBoolean(): Boolean = random.nextBoolean() - - def nextDouble(): Double = random.nextDouble() - - def nextId[T](): Id[T] = Id[T](nextUuid().toString) - - def nextId[T](maxLength: Int): Id[T] = Id[T](nextString(maxLength)) - - def nextNumericId[T](): Id[T] = Id[T](nextLong.abs.toString) - - def nextNumericId[T](maxValue: Int): Id[T] = Id[T](nextInt(maxValue).toString) - - def nextName[T](maxLength: Int = DefaultMaxLength): Name[T] = Name[T](nextString(maxLength)) - - def nextNonEmptyName[T](maxLength: Int = DefaultMaxLength): NonEmptyName[T] = - NonEmptyName[T](nextNonEmptyString(maxLength)) - - def nextUuid(): UUID = java.util.UUID.randomUUID - - def nextRevision[T](): Revision[T] = Revision[T](nextUuid().toString) - - def nextString(maxLength: Int = DefaultMaxLength): String = - (oneOf[Char](StringLetters) +: arrayOf(oneOf[Char](StringLetters), maxLength - 1)).mkString - - def nextNonEmptyString(maxLength: Int = DefaultMaxLength): String Refined NonEmpty = { - refineV[NonEmpty]( - (oneOf[Char](StringLetters) +: arrayOf(oneOf[Char](StringLetters), maxLength - 1)).mkString - ).right.get - } - - def nextOption[T](value: => T): Option[T] = if (nextBoolean()) Option(value) else None - - def nextPair[L, R](left: => L, right: => R): (L, R) = (left, right) - - def nextTriad[F, S, T](first: => F, second: => S, third: => T): (F, S, T) = (first, second, third) - - def nextInstant(): Instant = Instant.ofEpochMilli(math.abs(nextLong() % System.currentTimeMillis)) - - def nextTime(): Time = nextInstant() - - def nextTimeOfDay: TimeOfDay = TimeOfDay(java.time.LocalTime.MIN.plusSeconds(nextLong), java.util.TimeZone.getDefault) - - def nextTimeRange(): TimeRange = { - val oneTime = nextTime() - val anotherTime = nextTime() - - TimeRange( - Time(scala.math.min(oneTime.millis, anotherTime.millis)), - Time(scala.math.max(oneTime.millis, anotherTime.millis))) - } - - def nextDate(): Date = nextTime().toDate(java.util.TimeZone.getTimeZone("UTC")) - - def nextLocalDate(): LocalDate = nextInstant().atZone(ZoneOffset.UTC).toLocalDate - - def nextDayOfWeek(): DayOfWeek = oneOf(DayOfWeek.All) - - def nextBigDecimal(multiplier: Double = 1000000.00, precision: Int = 2): BigDecimal = - BigDecimal(multiplier * nextDouble, new MathContext(precision)) - - def oneOf[T](items: T*): T = oneOf(items.toSet) - - def oneOf[T](items: Set[T]): T = items.toSeq(nextInt(items.size)) - - def oneOf[T <: EnumEntry](enum: Enum[T]): T = oneOf(enum.values: _*) - - def arrayOf[T: ClassTag](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Array[T] = - Array.fill(nextInt(maxLength, minLength))(generator) - - def seqOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Seq[T] = - Seq.fill(nextInt(maxLength, minLength))(generator) - - def vectorOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Vector[T] = - Vector.fill(nextInt(maxLength, minLength))(generator) - - def listOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): List[T] = - List.fill(nextInt(maxLength, minLength))(generator) - - def setOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Set[T] = - seqOf(generator, maxLength, minLength).toSet - - def mapOf[K, V]( - keyGenerator: => K, - valueGenerator: => V, - maxLength: Int = DefaultMaxLength, - minLength: Int = 0): Map[K, V] = - seqOf(nextPair(keyGenerator, valueGenerator), maxLength, minLength).toMap -} diff --git a/src/main/scala/xyz/driver/core/json.scala b/src/main/scala/xyz/driver/core/json.scala deleted file mode 100644 index edc2347..0000000 --- a/src/main/scala/xyz/driver/core/json.scala +++ /dev/null @@ -1,398 +0,0 @@ -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") - } - -} diff --git a/src/main/scala/xyz/driver/core/rest/DnsDiscovery.scala b/src/main/scala/xyz/driver/core/rest/DnsDiscovery.scala deleted file mode 100644 index 87946e4..0000000 --- a/src/main/scala/xyz/driver/core/rest/DnsDiscovery.scala +++ /dev/null @@ -1,11 +0,0 @@ -package xyz.driver.core -package rest - -class DnsDiscovery(transport: HttpRestServiceTransport, overrides: Map[String, String]) { - - def discover[A](implicit descriptor: ServiceDescriptor[A]): A = { - val url = overrides.getOrElse(descriptor.name, s"https://${descriptor.name}") - descriptor.connect(transport, url) - } - -} diff --git a/src/main/scala/xyz/driver/core/rest/DriverRoute.scala b/src/main/scala/xyz/driver/core/rest/DriverRoute.scala deleted file mode 100644 index 911e306..0000000 --- a/src/main/scala/xyz/driver/core/rest/DriverRoute.scala +++ /dev/null @@ -1,122 +0,0 @@ -package xyz.driver.core.rest - -import java.sql.SQLException - -import akka.http.scaladsl.model.headers.CacheDirectives.`no-cache` -import akka.http.scaladsl.model.headers._ -import akka.http.scaladsl.model.{StatusCodes, _} -import akka.http.scaladsl.server.Directives._ -import akka.http.scaladsl.server._ -import com.typesafe.scalalogging.Logger -import org.slf4j.MDC -import xyz.driver.core.rest -import xyz.driver.core.rest.errors._ - -import scala.compat.Platform.ConcurrentModificationException - -trait DriverRoute { - def log: Logger - - def route: Route - - def routeWithDefaults: Route = { - (defaultResponseHeaders & handleExceptions(ExceptionHandler(exceptionHandler))) { - route - } - } - - protected def defaultResponseHeaders: Directive0 = { - extractRequest flatMap { request => - // Needs to happen before any request processing, so all the log messages - // associated with processing of this request are having this `trackingId` - val trackingId = rest.extractTrackingId(request) - val tracingHeader = RawHeader(ContextHeaders.TrackingIdHeader, trackingId) - MDC.put("trackingId", trackingId) - - respondWithHeaders(tracingHeader +: DriverRoute.DefaultHeaders: _*) - } - } - - /** - * Override me for custom exception handling - * - * @return Exception handling route for exception type - */ - protected def exceptionHandler: PartialFunction[Throwable, Route] = { - case serviceException: ServiceException => - serviceExceptionHandler(serviceException) - - case is: IllegalStateException => - ctx => - log.warn(s"Request is not allowed to ${ctx.request.method} ${ctx.request.uri}", is) - errorResponse(StatusCodes.BadRequest, message = is.getMessage, is)(ctx) - - case cm: ConcurrentModificationException => - ctx => - log.warn(s"Concurrent modification of the resource ${ctx.request.method} ${ctx.request.uri}", cm) - errorResponse(StatusCodes.Conflict, "Resource was changed concurrently, try requesting a newer version", cm)( - ctx) - - case se: SQLException => - ctx => - log.warn(s"Database exception for the resource ${ctx.request.method} ${ctx.request.uri}", se) - errorResponse(StatusCodes.InternalServerError, "Data access error", se)(ctx) - - case t: Exception => - ctx => - log.warn(s"Request to ${ctx.request.method} ${ctx.request.uri} could not be handled normally", t) - errorResponse(StatusCodes.InternalServerError, t.getMessage, t)(ctx) - } - - protected def serviceExceptionHandler(serviceException: ServiceException): Route = { - val statusCode = serviceException match { - case e: InvalidInputException => - log.info("Invalid client input error", e) - StatusCodes.BadRequest - case e: InvalidActionException => - log.info("Invalid client action error", e) - StatusCodes.Forbidden - case e: UnauthorizedException => - log.info("Unauthorized user error", e) - StatusCodes.Unauthorized - case e: ResourceNotFoundException => - log.info("Resource not found error", e) - StatusCodes.NotFound - case e: ExternalServiceException => - log.error("Error while calling another service", e) - StatusCodes.InternalServerError - case e: ExternalServiceTimeoutException => - log.error("Service timeout error", e) - StatusCodes.GatewayTimeout - case e: DatabaseException => - log.error("Database error", e) - StatusCodes.InternalServerError - } - - { (ctx: RequestContext) => - import xyz.driver.core.json.serviceExceptionFormat - val entity = - HttpEntity(ContentTypes.`application/json`, serviceExceptionFormat.write(serviceException).toString()) - errorResponse(statusCode, entity, serviceException)(ctx) - } - } - - protected def errorResponse[T <: Exception](statusCode: StatusCode, message: String, exception: T): Route = - errorResponse(statusCode, HttpEntity(message), exception) - - protected def errorResponse[T <: Exception](statusCode: StatusCode, entity: ResponseEntity, exception: T): Route = { - complete(HttpResponse(statusCode, entity = entity)) - } - -} - -object DriverRoute { - val DefaultHeaders: List[HttpHeader] = List( - // This header will eliminate the risk of envoy trying to reuse a connection - // that already timed out on the server side by completely rejecting keep-alive - Connection("close"), - // These 2 headers are the simplest way to prevent IE from caching GET requests - RawHeader("Pragma", "no-cache"), - `Cache-Control`(List(`no-cache`(Nil))) - ) -} diff --git a/src/main/scala/xyz/driver/core/rest/HttpRestServiceTransport.scala b/src/main/scala/xyz/driver/core/rest/HttpRestServiceTransport.scala deleted file mode 100644 index e31635b..0000000 --- a/src/main/scala/xyz/driver/core/rest/HttpRestServiceTransport.scala +++ /dev/null @@ -1,103 +0,0 @@ -package xyz.driver.core.rest - -import akka.actor.ActorSystem -import akka.http.scaladsl.model._ -import akka.http.scaladsl.model.headers.RawHeader -import akka.http.scaladsl.unmarshalling.Unmarshal -import akka.stream.Materializer -import akka.stream.scaladsl.TcpIdleTimeoutException -import org.slf4j.MDC -import xyz.driver.core.Name -import xyz.driver.core.reporting.Reporter -import xyz.driver.core.rest.errors.{ExternalServiceException, ExternalServiceTimeoutException} - -import scala.concurrent.{ExecutionContext, Future} -import scala.util.{Failure, Success} - -class HttpRestServiceTransport( - applicationName: Name[App], - applicationVersion: String, - val actorSystem: ActorSystem, - val executionContext: ExecutionContext, - reporter: Reporter) - extends ServiceTransport { - - protected implicit val execution: ExecutionContext = executionContext - - protected val httpClient: HttpClient = new SingleRequestHttpClient(applicationName, applicationVersion, actorSystem) - - def sendRequestGetResponse(context: ServiceRequestContext)(requestStub: HttpRequest): Future[HttpResponse] = { - val tags = Map( - // open tracing semantic tags - "span.kind" -> "client", - "service" -> applicationName.value, - "http.url" -> requestStub.uri.toString, - "http.method" -> requestStub.method.value, - "peer.hostname" -> requestStub.uri.authority.host.toString, - // google's tracing console provides extra search features if we define these tags - "/http/path" -> requestStub.uri.path.toString, - "/http/method" -> requestStub.method.value.toString, - "/http/url" -> requestStub.uri.toString - ) - reporter.traceAsync(s"http_call_rpc", tags) { implicit span => - val requestTime = System.currentTimeMillis() - - val request = requestStub - .withHeaders(context.contextHeaders.toSeq.map { - case (ContextHeaders.TrackingIdHeader, _) => - RawHeader(ContextHeaders.TrackingIdHeader, context.trackingId) - case (ContextHeaders.StacktraceHeader, _) => - RawHeader( - ContextHeaders.StacktraceHeader, - Option(MDC.get("stack")) - .orElse(context.contextHeaders.get(ContextHeaders.StacktraceHeader)) - .getOrElse("")) - case (header, headerValue) => RawHeader(header, headerValue) - }: _*) - - reporter.debug(s"Sending request to ${request.method} ${request.uri}") - - val response = httpClient.makeRequest(request) - - response.onComplete { - case Success(r) => - val responseLatency = System.currentTimeMillis() - requestTime - reporter.debug( - s"Response from ${request.uri} to request $requestStub is successful in $responseLatency ms: $r") - - case Failure(t: Throwable) => - val responseLatency = System.currentTimeMillis() - requestTime - reporter.warn( - s"Failed to receive response from ${request.method.value} ${request.uri} in $responseLatency ms", - t) - }(executionContext) - - response.recoverWith { - case _: TcpIdleTimeoutException => - val serviceCalled = s"${requestStub.method.value} ${requestStub.uri}" - Future.failed(ExternalServiceTimeoutException(serviceCalled)) - case t: Throwable => Future.failed(t) - } - }(context.spanContext) - } - - def sendRequest(context: ServiceRequestContext)(requestStub: HttpRequest)( - implicit mat: Materializer): Future[Unmarshal[ResponseEntity]] = { - - sendRequestGetResponse(context)(requestStub) flatMap { response => - if (response.status == StatusCodes.NotFound) { - Future.successful(Unmarshal(HttpEntity.Empty: ResponseEntity)) - } else if (response.status.isFailure()) { - val serviceCalled = s"${requestStub.method} ${requestStub.uri}" - Unmarshal(response.entity).to[String] flatMap { errorString => - import spray.json._ - import xyz.driver.core.json._ - val serviceException = util.Try(serviceExceptionFormat.read(errorString.parseJson)).toOption - Future.failed(ExternalServiceException(serviceCalled, errorString, serviceException)) - } - } else { - Future.successful(Unmarshal(response.entity)) - } - } - } -} diff --git a/src/main/scala/xyz/driver/core/rest/PatchDirectives.scala b/src/main/scala/xyz/driver/core/rest/PatchDirectives.scala deleted file mode 100644 index f33bf9d..0000000 --- a/src/main/scala/xyz/driver/core/rest/PatchDirectives.scala +++ /dev/null @@ -1,104 +0,0 @@ -package xyz.driver.core.rest - -import akka.http.javadsl.server.Rejections -import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport -import akka.http.scaladsl.model.{ContentTypeRange, HttpCharsets, MediaType} -import akka.http.scaladsl.server._ -import akka.http.scaladsl.unmarshalling.{FromEntityUnmarshaller, Unmarshaller} -import spray.json._ - -import scala.concurrent.Future -import scala.util.{Failure, Success, Try} - -trait PatchDirectives extends Directives with SprayJsonSupport { - - /** Media type for patches to JSON values, as specified in [[https://tools.ietf.org/html/rfc7396 RFC 7396]]. */ - val `application/merge-patch+json`: MediaType.WithFixedCharset = - MediaType.applicationWithFixedCharset("merge-patch+json", HttpCharsets.`UTF-8`) - - /** Wraps a JSON value that represents a patch. - * The patch must given in the format specified in [[https://tools.ietf.org/html/rfc7396 RFC 7396]]. */ - case class PatchValue(value: JsValue) { - - /** Applies this patch to a given original JSON value. In other words, merges the original with this "diff". */ - def applyTo(original: JsValue): JsValue = mergeJsValues(original, value) - } - - /** Witness that the given patch may be applied to an original domain value. - * @tparam A type of the domain value - * @param patch the patch that may be applied to a domain value - * @param format a JSON format that enables serialization and deserialization of a domain value */ - case class Patchable[A](patch: PatchValue, format: RootJsonFormat[A]) { - - /** Applies the patch to a given domain object. The result will be a combination - * of the original value, updates with the fields specified in this witness' patch. */ - def applyTo(original: A): A = { - val serialized = format.write(original) - val merged = patch.applyTo(serialized) - val deserialized = format.read(merged) - deserialized - } - } - - implicit def patchValueUnmarshaller: FromEntityUnmarshaller[PatchValue] = - Unmarshaller.byteStringUnmarshaller - .andThen(sprayJsValueByteStringUnmarshaller) - .forContentTypes(ContentTypeRange(`application/merge-patch+json`)) - .map(js => PatchValue(js)) - - implicit def patchableUnmarshaller[A]( - implicit patchUnmarshaller: FromEntityUnmarshaller[PatchValue], - format: RootJsonFormat[A]): FromEntityUnmarshaller[Patchable[A]] = { - patchUnmarshaller.map(patch => Patchable[A](patch, format)) - } - - protected def mergeObjects(oldObj: JsObject, newObj: JsObject, maxLevels: Option[Int] = None): JsObject = { - JsObject((oldObj.fields.keys ++ newObj.fields.keys).map({ key => - val oldValue = oldObj.fields.getOrElse(key, JsNull) - val newValue = newObj.fields.get(key).fold(oldValue)(mergeJsValues(oldValue, _, maxLevels.map(_ - 1))) - key -> newValue - })(collection.breakOut): _*) - } - - protected def mergeJsValues(oldValue: JsValue, newValue: JsValue, maxLevels: Option[Int] = None): JsValue = { - def mergeError(typ: String): Nothing = - deserializationError(s"Expected $typ value, got $newValue") - - if (maxLevels.exists(_ < 0)) oldValue - else { - (oldValue, newValue) match { - case (_: JsString, newString @ (JsString(_) | JsNull)) => newString - case (_: JsString, _) => mergeError("string") - case (_: JsNumber, newNumber @ (JsNumber(_) | JsNull)) => newNumber - case (_: JsNumber, _) => mergeError("number") - case (_: JsBoolean, newBool @ (JsBoolean(_) | JsNull)) => newBool - case (_: JsBoolean, _) => mergeError("boolean") - case (_: JsArray, newArr @ (JsArray(_) | JsNull)) => newArr - case (_: JsArray, _) => mergeError("array") - case (oldObj: JsObject, newObj: JsObject) => mergeObjects(oldObj, newObj) - case (_: JsObject, JsNull) => JsNull - case (_: JsObject, _) => mergeError("object") - case (JsNull, _) => newValue - } - } - } - - def mergePatch[T](patchable: Patchable[T], retrieve: => Future[Option[T]]): Directive1[T] = - Directive { inner => requestCtx => - onSuccess(retrieve)({ - case Some(oldT) => - Try(patchable.applyTo(oldT)) - .transform[Route]( - mergedT => scala.util.Success(inner(Tuple1(mergedT))), { - case jsonException: DeserializationException => - Success(reject(Rejections.malformedRequestContent(jsonException.getMessage, jsonException))) - case t => Failure(t) - } - ) - .get // intentionally re-throw all other errors - case None => reject() - })(requestCtx) - } -} - -object PatchDirectives extends PatchDirectives diff --git a/src/main/scala/xyz/driver/core/rest/PooledHttpClient.scala b/src/main/scala/xyz/driver/core/rest/PooledHttpClient.scala deleted file mode 100644 index 2854257..0000000 --- a/src/main/scala/xyz/driver/core/rest/PooledHttpClient.scala +++ /dev/null @@ -1,67 +0,0 @@ -package xyz.driver.core.rest - -import akka.actor.ActorSystem -import akka.http.scaladsl.Http -import akka.http.scaladsl.model.headers.`User-Agent` -import akka.http.scaladsl.model.{HttpRequest, HttpResponse, Uri} -import akka.http.scaladsl.settings.{ClientConnectionSettings, ConnectionPoolSettings} -import akka.stream.scaladsl.{Keep, Sink, Source} -import akka.stream.{ActorMaterializer, OverflowStrategy, QueueOfferResult, ThrottleMode} -import xyz.driver.core.Name - -import scala.concurrent.{ExecutionContext, Future, Promise} -import scala.concurrent.duration._ -import scala.util.{Failure, Success} - -class PooledHttpClient( - baseUri: Uri, - applicationName: Name[App], - applicationVersion: String, - requestRateLimit: Int = 64, - requestQueueSize: Int = 1024)(implicit actorSystem: ActorSystem, executionContext: ExecutionContext) - extends HttpClient { - - private val host = baseUri.authority.host.toString() - private val port = baseUri.effectivePort - private val scheme = baseUri.scheme - - protected implicit val materializer: ActorMaterializer = ActorMaterializer()(actorSystem) - - private val clientConnectionSettings: ClientConnectionSettings = - ClientConnectionSettings(actorSystem).withUserAgentHeader( - Option(`User-Agent`(applicationName.value + "/" + applicationVersion))) - - private val connectionPoolSettings: ConnectionPoolSettings = ConnectionPoolSettings(actorSystem) - .withConnectionSettings(clientConnectionSettings) - - private val pool = if (scheme.equalsIgnoreCase("https")) { - Http().cachedHostConnectionPoolHttps[Promise[HttpResponse]](host, port, settings = connectionPoolSettings) - } else { - Http().cachedHostConnectionPool[Promise[HttpResponse]](host, port, settings = connectionPoolSettings) - } - - private val queue = Source - .queue[(HttpRequest, Promise[HttpResponse])](requestQueueSize, OverflowStrategy.dropNew) - .via(pool) - .throttle(requestRateLimit, 1.second, maximumBurst = requestRateLimit, ThrottleMode.shaping) - .toMat(Sink.foreach({ - case ((Success(resp), p)) => p.success(resp) - case ((Failure(e), p)) => p.failure(e) - }))(Keep.left) - .run - - def makeRequest(request: HttpRequest): Future[HttpResponse] = { - val responsePromise = Promise[HttpResponse]() - - queue.offer(request -> responsePromise).flatMap { - case QueueOfferResult.Enqueued => - responsePromise.future - case QueueOfferResult.Dropped => - Future.failed(new Exception(s"Request queue to the host $host is overflown")) - case QueueOfferResult.Failure(ex) => - Future.failed(ex) - case QueueOfferResult.QueueClosed => - Future.failed(new Exception("Queue was closed (pool shut down) while running the request")) - } - } -} diff --git a/src/main/scala/xyz/driver/core/rest/ProxyRoute.scala b/src/main/scala/xyz/driver/core/rest/ProxyRoute.scala deleted file mode 100644 index c0e9f99..0000000 --- a/src/main/scala/xyz/driver/core/rest/ProxyRoute.scala +++ /dev/null @@ -1,26 +0,0 @@ -package xyz.driver.core.rest - -import akka.http.scaladsl.server.{RequestContext, Route, RouteResult} -import com.typesafe.config.Config -import xyz.driver.core.Name - -import scala.concurrent.ExecutionContext - -trait ProxyRoute extends DriverRoute { - implicit val executionContext: ExecutionContext - val config: Config - val httpClient: HttpClient - - protected def proxyToService(serviceName: Name[Service]): Route = { ctx: RequestContext => - val httpScheme = config.getString(s"services.${serviceName.value}.httpScheme") - val baseUrl = config.getString(s"services.${serviceName.value}.baseUrl") - - val originalUri = ctx.request.uri - val originalRequest = ctx.request - - val newUri = originalUri.withScheme(httpScheme).withHost(baseUrl) - val newRequest = originalRequest.withUri(newUri) - - httpClient.makeRequest(newRequest).map(RouteResult.Complete) - } -} diff --git a/src/main/scala/xyz/driver/core/rest/RestService.scala b/src/main/scala/xyz/driver/core/rest/RestService.scala deleted file mode 100644 index 09d98b8..0000000 --- a/src/main/scala/xyz/driver/core/rest/RestService.scala +++ /dev/null @@ -1,86 +0,0 @@ -package xyz.driver.core.rest - -import akka.http.scaladsl.model._ -import akka.http.scaladsl.unmarshalling.{Unmarshal, Unmarshaller} -import akka.stream.Materializer - -import scala.concurrent.{ExecutionContext, Future} -import scalaz.{ListT, OptionT} - -trait RestService extends Service { - - import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ - import spray.json._ - - protected implicit val exec: ExecutionContext - protected implicit val materializer: Materializer - - implicit class ResponseEntityFoldable(entity: Unmarshal[ResponseEntity]) { - def fold[T](default: => T)(implicit um: Unmarshaller[ResponseEntity, T]): Future[T] = - if (entity.value.isKnownEmpty()) Future.successful[T](default) else entity.to[T] - } - - protected def unitResponse(request: Future[Unmarshal[ResponseEntity]]): OptionT[Future, Unit] = - OptionT[Future, Unit](request.flatMap(_.to[String]).map(_ => Option(()))) - - protected def optionalResponse[T](request: Future[Unmarshal[ResponseEntity]])( - implicit um: Unmarshaller[ResponseEntity, Option[T]]): OptionT[Future, T] = - OptionT[Future, T](request.flatMap(_.fold(Option.empty[T]))) - - protected def listResponse[T](request: Future[Unmarshal[ResponseEntity]])( - implicit um: Unmarshaller[ResponseEntity, List[T]]): ListT[Future, T] = - ListT[Future, T](request.flatMap(_.fold(List.empty[T]))) - - protected def jsonEntity(json: JsValue): RequestEntity = - HttpEntity(ContentTypes.`application/json`, json.compactPrint) - - protected def mergePatchJsonEntity(json: JsValue): RequestEntity = - HttpEntity(PatchDirectives.`application/merge-patch+json`, json.compactPrint) - - protected def get(baseUri: Uri, path: String, query: Seq[(String, String)] = Seq.empty) = - HttpRequest(HttpMethods.GET, endpointUri(baseUri, path, query)) - - protected def post(baseUri: Uri, path: String, httpEntity: RequestEntity) = - HttpRequest(HttpMethods.POST, endpointUri(baseUri, path), entity = httpEntity) - - protected def postJson(baseUri: Uri, path: String, json: JsValue) = - HttpRequest(HttpMethods.POST, endpointUri(baseUri, path), entity = jsonEntity(json)) - - protected def put(baseUri: Uri, path: String, httpEntity: RequestEntity) = - HttpRequest(HttpMethods.PUT, endpointUri(baseUri, path), entity = httpEntity) - - protected def putJson(baseUri: Uri, path: String, json: JsValue) = - HttpRequest(HttpMethods.PUT, endpointUri(baseUri, path), entity = jsonEntity(json)) - - protected def patch(baseUri: Uri, path: String, httpEntity: RequestEntity) = - HttpRequest(HttpMethods.PATCH, endpointUri(baseUri, path), entity = httpEntity) - - protected def patchJson(baseUri: Uri, path: String, json: JsValue) = - HttpRequest(HttpMethods.PATCH, endpointUri(baseUri, path), entity = jsonEntity(json)) - - protected def mergePatchJson(baseUri: Uri, path: String, json: JsValue) = - HttpRequest(HttpMethods.PATCH, endpointUri(baseUri, path), entity = mergePatchJsonEntity(json)) - - protected def delete(baseUri: Uri, path: String, query: Seq[(String, String)] = Seq.empty) = - HttpRequest(HttpMethods.DELETE, endpointUri(baseUri, path, query)) - - protected def endpointUri(baseUri: Uri, path: String): Uri = - baseUri.withPath(Uri.Path(path)) - - protected def endpointUri(baseUri: Uri, path: String, query: Seq[(String, String)]): Uri = - baseUri.withPath(Uri.Path(path)).withQuery(Uri.Query(query: _*)) - - protected def responseToListResponse[T: JsonFormat](pagination: Option[Pagination])( - response: HttpResponse): Future[ListResponse[T]] = { - import DefaultJsonProtocol._ - val resourceCount = response.headers - .find(_.name() equalsIgnoreCase ContextHeaders.ResourceCount) - .map(_.value().toInt) - .getOrElse(0) - val meta = ListResponse.Meta(resourceCount, pagination.getOrElse(Pagination(resourceCount max 1, 1))) - Unmarshal(response.entity).to[List[T]].map(ListResponse(_, meta)) - } - - protected def responseToListResponse[T: JsonFormat](pagination: Pagination)( - response: HttpResponse): Future[ListResponse[T]] = responseToListResponse(Some(pagination))(response) -} diff --git a/src/main/scala/xyz/driver/core/rest/ServiceDescriptor.scala b/src/main/scala/xyz/driver/core/rest/ServiceDescriptor.scala deleted file mode 100644 index 646fae8..0000000 --- a/src/main/scala/xyz/driver/core/rest/ServiceDescriptor.scala +++ /dev/null @@ -1,16 +0,0 @@ -package xyz.driver.core -package rest -import scala.annotation.implicitNotFound - -@implicitNotFound( - "Don't know how to communicate with service ${S}. Make sure an implicit ServiceDescriptor is" + - "available. A good place to put one is in the service's companion object.") -trait ServiceDescriptor[S] { - - /** The service's name. Must be unique among all services. */ - def name: String - - /** Get an instance of the service. */ - def connect(transport: HttpRestServiceTransport, url: String): S - -} diff --git a/src/main/scala/xyz/driver/core/rest/SingleRequestHttpClient.scala b/src/main/scala/xyz/driver/core/rest/SingleRequestHttpClient.scala deleted file mode 100644 index 964a5a2..0000000 --- a/src/main/scala/xyz/driver/core/rest/SingleRequestHttpClient.scala +++ /dev/null @@ -1,29 +0,0 @@ -package xyz.driver.core.rest - -import akka.actor.ActorSystem -import akka.http.scaladsl.Http -import akka.http.scaladsl.model.headers.`User-Agent` -import akka.http.scaladsl.model.{HttpRequest, HttpResponse} -import akka.http.scaladsl.settings.{ClientConnectionSettings, ConnectionPoolSettings} -import akka.stream.ActorMaterializer -import xyz.driver.core.Name - -import scala.concurrent.Future - -class SingleRequestHttpClient(applicationName: Name[App], applicationVersion: String, actorSystem: ActorSystem) - extends HttpClient { - - protected implicit val materializer: ActorMaterializer = ActorMaterializer()(actorSystem) - private val client = Http()(actorSystem) - - private val clientConnectionSettings: ClientConnectionSettings = - ClientConnectionSettings(actorSystem).withUserAgentHeader( - Option(`User-Agent`(applicationName.value + "/" + applicationVersion))) - - private val connectionPoolSettings: ConnectionPoolSettings = ConnectionPoolSettings(actorSystem) - .withConnectionSettings(clientConnectionSettings) - - def makeRequest(request: HttpRequest): Future[HttpResponse] = { - client.singleRequest(request, settings = connectionPoolSettings) - } -} diff --git a/src/main/scala/xyz/driver/core/rest/Swagger.scala b/src/main/scala/xyz/driver/core/rest/Swagger.scala deleted file mode 100644 index 5ceac54..0000000 --- a/src/main/scala/xyz/driver/core/rest/Swagger.scala +++ /dev/null @@ -1,144 +0,0 @@ -package xyz.driver.core.rest - -import akka.http.scaladsl.model.{ContentType, ContentTypes, HttpEntity} -import akka.http.scaladsl.server.Route -import akka.http.scaladsl.server.directives.FileAndResourceDirectives.ResourceFile -import akka.stream.ActorAttributes -import akka.stream.scaladsl.{Framing, StreamConverters} -import akka.util.ByteString -import com.github.swagger.akka.SwaggerHttpService -import com.github.swagger.akka.model._ -import com.typesafe.config.Config -import com.typesafe.scalalogging.Logger -import io.swagger.models.Scheme -import io.swagger.models.auth.{ApiKeyAuthDefinition, In} -import io.swagger.util.Json - -import scala.util.control.NonFatal - -class Swagger( - override val host: String, - accessSchemes: List[String], - version: String, - override val apiClasses: Set[Class[_]], - val config: Config, - val logger: Logger) - extends SwaggerHttpService { - - override val schemes = accessSchemes.map { s => - Scheme.forValue(s) - } - - // Note that the reason for overriding this is a subtle chain of causality: - // - // 1. Some of our endpoints require a single trailing slash and will not - // function if it is omitted - // 2. Swagger omits trailing slashes in its generated api doc - // 3. To work around that, a space is added after the trailing slash in the - // swagger Path annotations - // 4. This space is removed manually in the code below - // - // TODO: Ideally we'd like to drop this custom override and fix the issue in - // 1, by dropping the slash requirement and accepting api endpoints with and - // without trailing slashes. This will require inspecting and potentially - // fixing all service endpoints. - override def generateSwaggerJson: String = { - import io.swagger.models.{Swagger => JSwagger} - - import scala.collection.JavaConverters._ - try { - val swagger: JSwagger = reader.read(apiClasses.asJava) - - val paths = if (swagger.getPaths == null) { - Map.empty - } else { - swagger.getPaths.asScala - } - - // Removing trailing spaces - val fixedPaths = paths.map { - case (key, path) => - key.trim -> path - } - - swagger.setPaths(fixedPaths.asJava) - - Json.pretty().writeValueAsString(swagger) - } catch { - case NonFatal(t) => - logger.error("Issue with creating swagger.json", t) - throw t - } - } - - override val securitySchemeDefinitions = Map( - "token" -> { - val definition = new ApiKeyAuthDefinition("Authorization", In.HEADER) - definition.setDescription("Authentication token") - definition - } - ) - - override val basePath: String = config.getString("swagger.basePath") - override val apiDocsPath: String = config.getString("swagger.docsPath") - - override val info = Info( - config.getString("swagger.apiInfo.description"), - version, - config.getString("swagger.apiInfo.title"), - config.getString("swagger.apiInfo.termsOfServiceUrl"), - contact = Some( - Contact( - config.getString("swagger.apiInfo.contact.name"), - config.getString("swagger.apiInfo.contact.url"), - config.getString("swagger.apiInfo.contact.email") - )), - license = Some( - License( - config.getString("swagger.apiInfo.license"), - config.getString("swagger.apiInfo.licenseUrl") - )), - vendorExtensions = Map.empty[String, AnyRef] - ) - - /** A very simple templating extractor. Gets a resource from the classpath and subsitutes any `{{key}}` with a value. */ - private def getTemplatedResource( - resourceName: String, - contentType: ContentType, - substitution: (String, String)): Route = get { - Option(this.getClass.getClassLoader.getResource(resourceName)) flatMap ResourceFile.apply match { - case Some(ResourceFile(url, length @ _, _)) => - extractSettings { settings => - val stream = StreamConverters - .fromInputStream(() => url.openStream()) - .withAttributes(ActorAttributes.dispatcher(settings.fileIODispatcher)) - .via(Framing.delimiter(ByteString("\n"), 4096, true).map(_.utf8String)) - .map { line => - line.replaceAll(s"\\{\\{${substitution._1}\\}\\}", substitution._2) - } - .map(line => ByteString(line + "\n")) - complete( - HttpEntity(contentType, stream) - ) - } - case None => reject - } - } - - def swaggerUI: Route = - pathEndOrSingleSlash { - getTemplatedResource( - "swagger-ui/index.html", - ContentTypes.`text/html(UTF-8)`, - "title" -> config.getString("swagger.apiInfo.title")) - } ~ getFromResourceDirectory("swagger-ui") - - def swaggerUINew: Route = - pathEndOrSingleSlash { - getTemplatedResource( - "swagger-ui-dist/index.html", - ContentTypes.`text/html(UTF-8)`, - "title" -> config.getString("swagger.apiInfo.title")) - } ~ getFromResourceDirectory("swagger-ui-dist") - -} diff --git a/src/main/scala/xyz/driver/core/rest/auth/AlwaysAllowAuthorization.scala b/src/main/scala/xyz/driver/core/rest/auth/AlwaysAllowAuthorization.scala deleted file mode 100644 index 5007774..0000000 --- a/src/main/scala/xyz/driver/core/rest/auth/AlwaysAllowAuthorization.scala +++ /dev/null @@ -1,14 +0,0 @@ -package xyz.driver.core.rest.auth - -import xyz.driver.core.auth.{Permission, User} -import xyz.driver.core.rest.ServiceRequestContext - -import scala.concurrent.Future - -class AlwaysAllowAuthorization[U <: User] extends Authorization[U] { - override def userHasPermissions(user: U, permissions: Seq[Permission])( - implicit ctx: ServiceRequestContext): Future[AuthorizationResult] = { - val permissionsMap = permissions.map(_ -> true).toMap - Future.successful(AuthorizationResult(authorized = permissionsMap, ctx.permissionsToken)) - } -} diff --git a/src/main/scala/xyz/driver/core/rest/auth/AuthProvider.scala b/src/main/scala/xyz/driver/core/rest/auth/AuthProvider.scala deleted file mode 100644 index e1a94e1..0000000 --- a/src/main/scala/xyz/driver/core/rest/auth/AuthProvider.scala +++ /dev/null @@ -1,75 +0,0 @@ -package xyz.driver.core.rest.auth - -import akka.http.scaladsl.server.directives.Credentials -import com.typesafe.scalalogging.Logger -import scalaz.OptionT -import xyz.driver.core.auth.{AuthToken, Permission, User} -import xyz.driver.core.rest.errors.{ExternalServiceException, UnauthorizedException} -import xyz.driver.core.rest.{AuthorizedServiceRequestContext, ContextHeaders, ServiceRequestContext, serviceContext} - -import scala.concurrent.{ExecutionContext, Future} - -abstract class AuthProvider[U <: User]( - val authorization: Authorization[U], - log: Logger, - val realm: String -)(implicit execution: ExecutionContext) { - - import akka.http.scaladsl.server._ - import Directives.{authorize => akkaAuthorize, _} - - def this(authorization: Authorization[U], log: Logger)(implicit executionContext: ExecutionContext) = - this(authorization, log, "driver.xyz") - - /** - * Specific implementation on how to extract user from request context, - * can either need to do a network call to auth server or extract everything from self-contained token - * - * @param ctx set of request values which can be relevant to authenticate user - * @return authenticated user - */ - def authenticatedUser(implicit ctx: ServiceRequestContext): OptionT[Future, U] - - protected def authenticator(context: ServiceRequestContext): AsyncAuthenticator[U] = { - case Credentials.Missing => - log.info(s"Request (${context.trackingId}) missing authentication credentials") - Future.successful(None) - case Credentials.Provided(authToken) => - authenticatedUser(context.withAuthToken(AuthToken(authToken))).run.recover({ - case ExternalServiceException(_, _, Some(UnauthorizedException(_))) => None - }) - } - - /** - * Verifies that a user agent is properly authenticated, and (optionally) authorized with the specified permissions - */ - def authorize( - context: ServiceRequestContext, - permissions: Permission*): Directive1[AuthorizedServiceRequestContext[U]] = { - authenticateOAuth2Async[U](realm, authenticator(context)) flatMap { authenticatedUser => - val authCtx = context.withAuthenticatedUser(context.authToken.get, authenticatedUser) - onSuccess(authorization.userHasPermissions(authenticatedUser, permissions)(authCtx)) flatMap { - case AuthorizationResult(authorized, token) => - val allAuthorized = permissions.forall(authorized.getOrElse(_, false)) - akkaAuthorize(allAuthorized) tflatMap { _ => - val cachedPermissionsCtx = token.fold(authCtx)(authCtx.withPermissionsToken) - provide(cachedPermissionsCtx) - } - } - } - } - - /** - * Verifies if request is authenticated and authorized to have `permissions` - */ - def authorize(permissions: Permission*): Directive1[AuthorizedServiceRequestContext[U]] = { - serviceContext flatMap (authorize(_, permissions: _*)) - } -} - -object AuthProvider { - val AuthenticationTokenHeader: String = ContextHeaders.AuthenticationTokenHeader - val PermissionsTokenHeader: String = ContextHeaders.PermissionsTokenHeader - val SetAuthenticationTokenHeader: String = "set-authorization" - val SetPermissionsTokenHeader: String = "set-permissions" -} diff --git a/src/main/scala/xyz/driver/core/rest/auth/Authorization.scala b/src/main/scala/xyz/driver/core/rest/auth/Authorization.scala deleted file mode 100644 index 1a5e9be..0000000 --- a/src/main/scala/xyz/driver/core/rest/auth/Authorization.scala +++ /dev/null @@ -1,11 +0,0 @@ -package xyz.driver.core.rest.auth - -import xyz.driver.core.auth.{Permission, User} -import xyz.driver.core.rest.ServiceRequestContext - -import scala.concurrent.Future - -trait Authorization[U <: User] { - def userHasPermissions(user: U, permissions: Seq[Permission])( - implicit ctx: ServiceRequestContext): Future[AuthorizationResult] -} diff --git a/src/main/scala/xyz/driver/core/rest/auth/AuthorizationResult.scala b/src/main/scala/xyz/driver/core/rest/auth/AuthorizationResult.scala deleted file mode 100644 index efe28c9..0000000 --- a/src/main/scala/xyz/driver/core/rest/auth/AuthorizationResult.scala +++ /dev/null @@ -1,22 +0,0 @@ -package xyz.driver.core.rest.auth - -import xyz.driver.core.auth.{Permission, PermissionsToken} - -import scalaz.Scalaz.mapMonoid -import scalaz.Semigroup -import scalaz.syntax.semigroup._ - -final case class AuthorizationResult(authorized: Map[Permission, Boolean], token: Option[PermissionsToken]) -object AuthorizationResult { - val unauthorized: AuthorizationResult = AuthorizationResult(authorized = Map.empty, None) - - implicit val authorizationSemigroup: Semigroup[AuthorizationResult] = new Semigroup[AuthorizationResult] { - private implicit val authorizedBooleanSemigroup = Semigroup.instance[Boolean](_ || _) - private implicit val permissionsTokenSemigroup = - Semigroup.instance[Option[PermissionsToken]]((a, b) => b.orElse(a)) - - override def append(a: AuthorizationResult, b: => AuthorizationResult): AuthorizationResult = { - AuthorizationResult(a.authorized |+| b.authorized, a.token |+| b.token) - } - } -} diff --git a/src/main/scala/xyz/driver/core/rest/auth/CachedTokenAuthorization.scala b/src/main/scala/xyz/driver/core/rest/auth/CachedTokenAuthorization.scala deleted file mode 100644 index 66de4ef..0000000 --- a/src/main/scala/xyz/driver/core/rest/auth/CachedTokenAuthorization.scala +++ /dev/null @@ -1,55 +0,0 @@ -package xyz.driver.core.rest.auth - -import java.nio.file.{Files, Path} -import java.security.{KeyFactory, PublicKey} -import java.security.spec.X509EncodedKeySpec - -import pdi.jwt.{Jwt, JwtAlgorithm} -import xyz.driver.core.auth.{Permission, User} -import xyz.driver.core.rest.ServiceRequestContext - -import scala.concurrent.Future -import scalaz.syntax.std.boolean._ - -class CachedTokenAuthorization[U <: User](publicKey: => PublicKey, issuer: String) extends Authorization[U] { - override def userHasPermissions(user: U, permissions: Seq[Permission])( - implicit ctx: ServiceRequestContext): Future[AuthorizationResult] = { - import spray.json._ - - def extractPermissionsFromTokenJSON(tokenObject: JsObject): Option[Map[String, Boolean]] = - tokenObject.fields.get("permissions").collect { - case JsObject(fields) => - fields.collect { - case (key, JsBoolean(value)) => key -> value - } - } - - val result = for { - token <- ctx.permissionsToken - jwt <- Jwt.decode(token.value, publicKey, Seq(JwtAlgorithm.RS256)).toOption - jwtJson = jwt.parseJson.asJsObject - - // Ensure jwt is for the currently authenticated user and the correct issuer, otherwise return None - _ <- jwtJson.fields.get("sub").contains(JsString(user.id.value)).option(()) - _ <- jwtJson.fields.get("iss").contains(JsString(issuer)).option(()) - - permissionsMap <- extractPermissionsFromTokenJSON(jwtJson) - - authorized = permissions.map(p => p -> permissionsMap.getOrElse(p.toString, false)).toMap - } yield AuthorizationResult(authorized, Some(token)) - - Future.successful(result.getOrElse(AuthorizationResult.unauthorized)) - } -} - -object CachedTokenAuthorization { - def apply[U <: User](publicKeyFile: Path, issuer: String): CachedTokenAuthorization[U] = { - lazy val publicKey: PublicKey = { - val publicKeyBase64Encoded = new String(Files.readAllBytes(publicKeyFile)).trim - val publicKeyBase64Decoded = java.util.Base64.getDecoder.decode(publicKeyBase64Encoded) - val spec = new X509EncodedKeySpec(publicKeyBase64Decoded) - KeyFactory.getInstance("RSA").generatePublic(spec) - } - new CachedTokenAuthorization[U](publicKey, issuer) - } -} diff --git a/src/main/scala/xyz/driver/core/rest/auth/ChainedAuthorization.scala b/src/main/scala/xyz/driver/core/rest/auth/ChainedAuthorization.scala deleted file mode 100644 index 131e7fc..0000000 --- a/src/main/scala/xyz/driver/core/rest/auth/ChainedAuthorization.scala +++ /dev/null @@ -1,27 +0,0 @@ -package xyz.driver.core.rest.auth - -import xyz.driver.core.auth.{Permission, User} -import xyz.driver.core.rest.ServiceRequestContext - -import scala.concurrent.{ExecutionContext, Future} -import scalaz.Scalaz.{futureInstance, listInstance} -import scalaz.syntax.semigroup._ -import scalaz.syntax.traverse._ - -class ChainedAuthorization[U <: User](authorizations: Authorization[U]*)(implicit execution: ExecutionContext) - extends Authorization[U] { - - override def userHasPermissions(user: U, permissions: Seq[Permission])( - implicit ctx: ServiceRequestContext): Future[AuthorizationResult] = { - def allAuthorized(permissionsMap: Map[Permission, Boolean]): Boolean = - permissions.forall(permissionsMap.getOrElse(_, false)) - - authorizations.toList.foldLeftM[Future, AuthorizationResult](AuthorizationResult.unauthorized) { - (authResult, authorization) => - if (allAuthorized(authResult.authorized)) Future.successful(authResult) - else { - authorization.userHasPermissions(user, permissions).map(authResult |+| _) - } - } - } -} diff --git a/src/main/scala/xyz/driver/core/rest/directives/AuthDirectives.scala b/src/main/scala/xyz/driver/core/rest/directives/AuthDirectives.scala deleted file mode 100644 index ff3424d..0000000 --- a/src/main/scala/xyz/driver/core/rest/directives/AuthDirectives.scala +++ /dev/null @@ -1,19 +0,0 @@ -package xyz.driver.core -package rest -package directives - -import akka.http.scaladsl.server.{Directive1, Directives => AkkaDirectives} -import xyz.driver.core.auth.{Permission, User} -import xyz.driver.core.rest.auth.AuthProvider - -/** Authentication and authorization directives. */ -trait AuthDirectives extends AkkaDirectives { - - /** Authenticate a user based on service request headers and check if they have all given permissions. */ - def authenticateAndAuthorize[U <: User]( - authProvider: AuthProvider[U], - permissions: Permission*): Directive1[AuthorizedServiceRequestContext[U]] = { - authProvider.authorize(permissions: _*) - } - -} diff --git a/src/main/scala/xyz/driver/core/rest/directives/CorsDirectives.scala b/src/main/scala/xyz/driver/core/rest/directives/CorsDirectives.scala deleted file mode 100644 index 5a6bbfd..0000000 --- a/src/main/scala/xyz/driver/core/rest/directives/CorsDirectives.scala +++ /dev/null @@ -1,72 +0,0 @@ -package xyz.driver.core -package rest -package directives - -import akka.http.scaladsl.model.HttpMethods._ -import akka.http.scaladsl.model.headers._ -import akka.http.scaladsl.model.{HttpResponse, StatusCodes} -import akka.http.scaladsl.server.{Route, Directives => AkkaDirectives} - -/** Directives to handle Cross-Origin Resource Sharing (CORS). */ -trait CorsDirectives extends AkkaDirectives { - - /** Route handler that injects Cross-Origin Resource Sharing (CORS) headers depending on the request - * origin. - * - * In a microservice environment, it can be difficult to know in advance the exact origin - * from which requests may be issued [1]. For example, the request may come from a web page served from - * any of the services, on any namespace or from other documentation sites. In general, only a set - * of domain suffixes can be assumed to be known in advance. Unfortunately however, browsers that - * implement CORS require exact specification of allowed origins, including full host name and scheme, - * in order to send credentials and headers with requests to other origins. - * - * This route wrapper provides a simple way alleviate CORS' exact allowed-origin requirement by - * dynamically echoing the origin as an allowed origin if and only if its domain is whitelisted. - * - * Note that the simplicity of this implementation comes with two notable drawbacks: - * - * - All OPTION requests are "hijacked" and will not be passed to the inner route of this wrapper. - * - * - Allowed methods and headers can not be customized on a per-request basis. All standard - * HTTP methods are allowed, and allowed headers are specified for all inner routes. - * - * This handler is not suited for cases where more fine-grained control of responses is required. - * - * [1] Assuming browsers communicate directly with the services and that requests aren't proxied through - * a common gateway. - * - * @param allowedSuffixes The set of domain suffixes (e.g. internal.example.org, example.org) of allowed - * origins. - * @param allowedHeaders Header names that will be set in `Access-Control-Allow-Headers`. - * @param inner Route into which CORS headers will be injected. - */ - def cors(allowedSuffixes: Set[String], allowedHeaders: Seq[String])(inner: Route): Route = { - optionalHeaderValueByType[Origin](()) { maybeOrigin => - val allowedOrigins: HttpOriginRange = maybeOrigin match { - // Note that this is not a security issue: the client will never send credentials if the allowed - // origin is set to *. This case allows us to deal with clients that do not send an origin header. - case None => HttpOriginRange.* - case Some(requestOrigin) => - val allowedOrigin = requestOrigin.origins.find(origin => - allowedSuffixes.exists(allowed => origin.host.host.address endsWith allowed)) - allowedOrigin.map(HttpOriginRange(_)).getOrElse(HttpOriginRange.*) - } - - respondWithHeaders( - `Access-Control-Allow-Origin`.forRange(allowedOrigins), - `Access-Control-Allow-Credentials`(true), - `Access-Control-Allow-Headers`(allowedHeaders: _*), - `Access-Control-Expose-Headers`(allowedHeaders: _*) - ) { - options { // options is used during preflight check - complete( - HttpResponse(StatusCodes.OK) - .withHeaders(`Access-Control-Allow-Methods`(OPTIONS, POST, PUT, GET, DELETE, PATCH, TRACE))) - } ~ inner // in case of non-preflight check we don't do anything special - } - } - } - -} - -object CorsDirectives extends CorsDirectives diff --git a/src/main/scala/xyz/driver/core/rest/directives/Directives.scala b/src/main/scala/xyz/driver/core/rest/directives/Directives.scala deleted file mode 100644 index 0cd4ef1..0000000 --- a/src/main/scala/xyz/driver/core/rest/directives/Directives.scala +++ /dev/null @@ -1,6 +0,0 @@ -package xyz.driver.core -package rest -package directives - -trait Directives extends AuthDirectives with CorsDirectives with PathMatchers with Unmarshallers -object Directives extends Directives diff --git a/src/main/scala/xyz/driver/core/rest/directives/PathMatchers.scala b/src/main/scala/xyz/driver/core/rest/directives/PathMatchers.scala deleted file mode 100644 index 218c9ae..0000000 --- a/src/main/scala/xyz/driver/core/rest/directives/PathMatchers.scala +++ /dev/null @@ -1,85 +0,0 @@ -package xyz.driver.core -package rest -package directives - -import java.time.Instant -import java.util.UUID - -import akka.http.scaladsl.model.Uri.Path -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 - -/** Akka-HTTP path matchers for custom core types. */ -trait PathMatchers { - - private def UuidInPath[T]: PathMatcher1[Id[T]] = - AkkaPathMatchers.JavaUUID.map((id: UUID) => Id[T](id.toString.toLowerCase)) - - def IdInPath[T]: PathMatcher1[Id[T]] = UuidInPath[T] | new PathMatcher1[Id[T]] { - def apply(path: Path) = path match { - case Path.Segment(segment, tail) => Matched(tail, Tuple1(Id[T](segment))) - case _ => Unmatched - } - } - - def NameInPath[T]: PathMatcher1[Name[T]] = new PathMatcher1[Name[T]] { - def apply(path: Path) = path match { - case Path.Segment(segment, tail) => Matched(tail, Tuple1(Name[T](segment))) - case _ => Unmatched - } - } - - private def timestampInPath: PathMatcher1[Long] = - PathMatcher("""[+-]?\d*""".r) flatMap { string => - try Some(string.toLong) - catch { case _: IllegalArgumentException => None } - } - - def InstantInPath: PathMatcher1[Instant] = - new PathMatcher1[Instant] { - def apply(path: Path): PathMatcher.Matching[Tuple1[Instant]] = path match { - case Path.Segment(head, tail) => - try Matched(tail, Tuple1(Instant.parse(head))) - catch { - case NonFatal(_) => Unmatched - } - case _ => Unmatched - } - } | timestampInPath.map(Instant.ofEpochMilli) - - def TimeInPath: PathMatcher1[Time] = InstantInPath.map(instant => Time(instant.toEpochMilli)) - - def NonEmptyNameInPath[T]: PathMatcher1[NonEmptyName[T]] = new PathMatcher1[NonEmptyName[T]] { - def apply(path: Path) = path match { - case Path.Segment(segment, tail) => - refineV[NonEmpty](segment) match { - case Left(_) => Unmatched - case Right(nonEmptyString) => Matched(tail, Tuple1(NonEmptyName[T](nonEmptyString))) - } - case _ => Unmatched - } - } - - def RevisionInPath[T]: PathMatcher1[Revision[T]] = - PathMatcher("""[\da-fA-F]{8}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{12}""".r) flatMap { string => - 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/main/scala/xyz/driver/core/rest/directives/Unmarshallers.scala b/src/main/scala/xyz/driver/core/rest/directives/Unmarshallers.scala deleted file mode 100644 index 6c45d15..0000000 --- a/src/main/scala/xyz/driver/core/rest/directives/Unmarshallers.scala +++ /dev/null @@ -1,40 +0,0 @@ -package xyz.driver.core -package rest -package directives - -import java.util.UUID - -import akka.http.scaladsl.marshalling.{Marshaller, Marshalling} -import akka.http.scaladsl.unmarshalling.Unmarshaller -import spray.json.{JsString, JsValue, JsonParser, JsonReader, JsonWriter} - -/** Akka-HTTP unmarshallers for custom core types. */ -trait Unmarshallers { - - implicit def idUnmarshaller[A]: Unmarshaller[String, Id[A]] = - Unmarshaller.strict[String, Id[A]] { str => - Id[A](UUID.fromString(str).toString) - } - - implicit def paramUnmarshaller[T](implicit reader: JsonReader[T]): Unmarshaller[String, T] = - Unmarshaller.firstOf( - Unmarshaller.strict((JsString(_: String)) andThen reader.read), - stringToValueUnmarshaller[T] - ) - - implicit def revisionFromStringUnmarshaller[T]: Unmarshaller[String, Revision[T]] = - Unmarshaller.strict[String, Revision[T]](Revision[T]) - - val jsValueToStringMarshaller: Marshaller[JsValue, String] = - Marshaller.strict[JsValue, String](value => Marshalling.Opaque[String](() => value.compactPrint)) - - def valueToStringMarshaller[T](implicit jsonFormat: JsonWriter[T]): Marshaller[T, String] = - jsValueToStringMarshaller.compose[T](jsonFormat.write) - - val stringToJsValueUnmarshaller: Unmarshaller[String, JsValue] = - Unmarshaller.strict[String, JsValue](value => JsonParser(value)) - - def stringToValueUnmarshaller[T](implicit jsonFormat: JsonReader[T]): Unmarshaller[String, T] = - stringToJsValueUnmarshaller.map[T](jsonFormat.read) - -} diff --git a/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala b/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala deleted file mode 100644 index f2962c9..0000000 --- a/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala +++ /dev/null @@ -1,27 +0,0 @@ -package xyz.driver.core.rest.errors - -sealed abstract class ServiceException(val message: String) extends Exception(message) - -final case class InvalidInputException(override val message: String = "Invalid input") extends ServiceException(message) - -final case class InvalidActionException(override val message: String = "This action is not allowed") - extends ServiceException(message) - -final case class UnauthorizedException( - override val message: String = "The user's authentication credentials are invalid or missing") - extends ServiceException(message) - -final case class ResourceNotFoundException(override val message: String = "Resource not found") - extends ServiceException(message) - -final case class ExternalServiceException( - serviceName: String, - serviceMessage: String, - serviceException: Option[ServiceException]) - extends ServiceException(s"Error while calling '$serviceName': $serviceMessage") - -final case class ExternalServiceTimeoutException(serviceName: String) - extends ServiceException(s"$serviceName took too long to respond") - -final case class DatabaseException(override val message: String = "Database access error") - extends ServiceException(message) diff --git a/src/main/scala/xyz/driver/core/rest/headers/Traceparent.scala b/src/main/scala/xyz/driver/core/rest/headers/Traceparent.scala deleted file mode 100644 index 866476d..0000000 --- a/src/main/scala/xyz/driver/core/rest/headers/Traceparent.scala +++ /dev/null @@ -1,33 +0,0 @@ -package xyz.driver.core -package rest -package headers - -import akka.http.scaladsl.model.headers.{ModeledCustomHeader, ModeledCustomHeaderCompanion} -import xyz.driver.core.reporting.SpanContext - -import scala.util.Try - -/** Encapsulates a trace context in an HTTP header for propagation across services. - * - * This implementation corresponds to the W3C editor's draft specification (as of 2018-08-28) - * https://w3c.github.io/distributed-tracing/report-trace-context.html. The 'flags' field is - * ignored. - */ -final case class Traceparent(spanContext: SpanContext) extends ModeledCustomHeader[Traceparent] { - override def renderInRequests = true - override def renderInResponses = true - override val companion: Traceparent.type = Traceparent - override def value: String = f"01-${spanContext.traceId}-${spanContext.spanId}-00" -} -object Traceparent extends ModeledCustomHeaderCompanion[Traceparent] { - override val name = "traceparent" - override def parse(value: String) = Try { - val Array(version, traceId, spanId, _) = value.split("-") - require( - version == "01", - s"Found unsupported version '$version' in traceparent header. Only version '01' is supported.") - new Traceparent( - new SpanContext(traceId, spanId) - ) - } -} diff --git a/src/main/scala/xyz/driver/core/rest/package.scala b/src/main/scala/xyz/driver/core/rest/package.scala deleted file mode 100644 index 34a4a9d..0000000 --- a/src/main/scala/xyz/driver/core/rest/package.scala +++ /dev/null @@ -1,323 +0,0 @@ -package xyz.driver.core.rest - -import java.net.InetAddress - -import akka.http.scaladsl.marshalling.{ToEntityMarshaller, ToResponseMarshallable} -import akka.http.scaladsl.model._ -import akka.http.scaladsl.model.headers._ -import akka.http.scaladsl.server.Directives._ -import akka.http.scaladsl.server._ -import akka.http.scaladsl.unmarshalling.Unmarshal -import akka.stream.Materializer -import akka.stream.scaladsl.Flow -import akka.util.ByteString -import scalaz.Scalaz.{intInstance, stringInstance} -import scalaz.syntax.equal._ -import scalaz.{Functor, OptionT} -import xyz.driver.core.rest.auth.AuthProvider -import xyz.driver.core.rest.errors.ExternalServiceException -import xyz.driver.core.rest.headers.Traceparent -import xyz.driver.tracing.TracingDirectives - -import scala.concurrent.{ExecutionContext, Future} -import scala.util.Try - -trait Service - -object Service - -trait HttpClient { - def makeRequest(request: HttpRequest): Future[HttpResponse] -} - -trait ServiceTransport { - - def sendRequestGetResponse(context: ServiceRequestContext)(requestStub: HttpRequest): Future[HttpResponse] - - def sendRequest(context: ServiceRequestContext)(requestStub: HttpRequest)( - implicit mat: Materializer): Future[Unmarshal[ResponseEntity]] -} - -sealed trait SortingOrder -object SortingOrder { - case object Asc extends SortingOrder - case object Desc extends SortingOrder -} - -final case class SortingField(name: String, sortingOrder: SortingOrder) -final case class Sorting(sortingFields: Seq[SortingField]) - -final case class Pagination(pageSize: Int, pageNumber: Int) { - require(pageSize > 0, "Page size must be greater than zero") - require(pageNumber > 0, "Page number must be greater than zero") - - def offset: Int = pageSize * (pageNumber - 1) -} - -final case class ListResponse[+T](items: Seq[T], meta: ListResponse.Meta) - -object ListResponse { - - def apply[T](items: Seq[T], size: Int, pagination: Option[Pagination]): ListResponse[T] = - ListResponse( - items = items, - meta = ListResponse.Meta(size, pagination.fold(1)(_.pageNumber), pagination.fold(size)(_.pageSize))) - - final case class Meta(itemsCount: Int, pageNumber: Int, pageSize: Int) - - object Meta { - def apply(itemsCount: Int, pagination: Pagination): Meta = - Meta(itemsCount, pagination.pageNumber, pagination.pageSize) - } - -} - -object `package` { - - implicit class FutureExtensions[T](future: Future[T]) { - def passThroughExternalServiceException(implicit executionContext: ExecutionContext): Future[T] = - future.transform(identity, { - case ExternalServiceException(_, _, Some(e)) => e - case t: Throwable => t - }) - } - - implicit class OptionTRestAdditions[T](optionT: OptionT[Future, T]) { - def responseOrNotFound(successCode: StatusCodes.Success = StatusCodes.OK)( - implicit F: Functor[Future], - em: ToEntityMarshaller[T]): Future[ToResponseMarshallable] = { - optionT.fold[ToResponseMarshallable](successCode -> _, StatusCodes.NotFound -> None) - } - } - - object ContextHeaders { - val AuthenticationTokenHeader: String = "Authorization" - val PermissionsTokenHeader: String = "Permissions" - val AuthenticationHeaderPrefix: String = "Bearer" - val ClientFingerprintHeader: String = "X-Client-Fingerprint" - val TrackingIdHeader: String = "X-Trace" - val StacktraceHeader: String = "X-Stacktrace" - val OriginatingIpHeader: String = "X-Forwarded-For" - val ResourceCount: String = "X-Resource-Count" - val PageCount: String = "X-Page-Count" - val TraceHeaderName: String = TracingDirectives.TraceHeaderName - val SpanHeaderName: String = TracingDirectives.SpanHeaderName - } - - val AllowedHeaders: Seq[String] = - Seq( - "Origin", - "X-Requested-With", - "Content-Type", - "Content-Length", - "Accept", - "X-Trace", - "Access-Control-Allow-Methods", - "Access-Control-Allow-Origin", - "Access-Control-Allow-Headers", - "Server", - "Date", - ContextHeaders.ClientFingerprintHeader, - ContextHeaders.TrackingIdHeader, - ContextHeaders.TraceHeaderName, - ContextHeaders.SpanHeaderName, - ContextHeaders.StacktraceHeader, - ContextHeaders.AuthenticationTokenHeader, - ContextHeaders.OriginatingIpHeader, - ContextHeaders.ResourceCount, - ContextHeaders.PageCount, - "X-Frame-Options", - "X-Content-Type-Options", - "Strict-Transport-Security", - AuthProvider.SetAuthenticationTokenHeader, - AuthProvider.SetPermissionsTokenHeader, - "Traceparent" - ) - - def allowOrigin(originHeader: Option[Origin]): `Access-Control-Allow-Origin` = - `Access-Control-Allow-Origin`( - originHeader.fold[HttpOriginRange](HttpOriginRange.*)(h => HttpOriginRange(h.origins: _*))) - - def serviceContext: Directive1[ServiceRequestContext] = { - def fixAuthorizationHeader(headers: Seq[HttpHeader]): collection.immutable.Seq[HttpHeader] = { - headers.map({ header => - if (header.name === ContextHeaders.AuthenticationTokenHeader && !header.value.startsWith( - ContextHeaders.AuthenticationHeaderPrefix)) { - Authorization(OAuth2BearerToken(header.value)) - } else header - })(collection.breakOut) - } - extractClientIP flatMap { remoteAddress => - mapRequest(req => req.withHeaders(fixAuthorizationHeader(req.headers))) tflatMap { _ => - extract(ctx => extractServiceContext(ctx.request, remoteAddress)) - } - } - } - - def respondWithCorsAllowedHeaders: Directive0 = { - respondWithHeaders( - List[HttpHeader]( - `Access-Control-Allow-Headers`(AllowedHeaders: _*), - `Access-Control-Expose-Headers`(AllowedHeaders: _*) - )) - } - - def respondWithCorsAllowedOriginHeaders(origin: Origin): Directive0 = { - respondWithHeader { - `Access-Control-Allow-Origin`(HttpOriginRange(origin.origins: _*)) - } - } - - def respondWithCorsAllowedMethodHeaders(methods: Set[HttpMethod]): Directive0 = { - respondWithHeaders( - List[HttpHeader]( - Allow(methods.to[collection.immutable.Seq]), - `Access-Control-Allow-Methods`(methods.to[collection.immutable.Seq]) - )) - } - - def extractServiceContext(request: HttpRequest, remoteAddress: RemoteAddress): ServiceRequestContext = - new ServiceRequestContext( - extractTrackingId(request), - extractOriginatingIP(request, remoteAddress), - extractContextHeaders(request)) - - def extractTrackingId(request: HttpRequest): String = { - request.headers - .find(_.name === ContextHeaders.TrackingIdHeader) - .fold(java.util.UUID.randomUUID.toString)(_.value()) - } - - def extractFingerprintHash(request: HttpRequest): Option[String] = { - request.headers - .find(_.name === ContextHeaders.ClientFingerprintHeader) - .map(_.value()) - } - - def extractOriginatingIP(request: HttpRequest, remoteAddress: RemoteAddress): Option[InetAddress] = { - request.headers - .find(_.name === ContextHeaders.OriginatingIpHeader) - .flatMap(ipName => Try(InetAddress.getByName(ipName.value)).toOption) - .orElse(remoteAddress.toOption) - } - - def extractStacktrace(request: HttpRequest): Array[String] = - request.headers.find(_.name == ContextHeaders.StacktraceHeader).fold("")(_.value()).split("->") - - def extractContextHeaders(request: HttpRequest): Map[String, String] = { - request.headers - .filter { h => - h.name === ContextHeaders.AuthenticationTokenHeader || h.name === ContextHeaders.TrackingIdHeader || - h.name === ContextHeaders.PermissionsTokenHeader || h.name === ContextHeaders.StacktraceHeader || - h.name === ContextHeaders.TraceHeaderName || h.name === ContextHeaders.SpanHeaderName || - h.name === ContextHeaders.OriginatingIpHeader || h.name === ContextHeaders.ClientFingerprintHeader || - h.name === Traceparent.name - } - .map { header => - if (header.name === ContextHeaders.AuthenticationTokenHeader) { - header.name -> header.value.stripPrefix(ContextHeaders.AuthenticationHeaderPrefix).trim - } else { - header.name -> header.value - } - } - .toMap - } - - private[rest] def escapeScriptTags(byteString: ByteString): ByteString = { - @annotation.tailrec - def dirtyIndices(from: Int, descIndices: List[Int]): List[Int] = { - val index = byteString.indexOf('/', from) - if (index === -1) descIndices.reverse - else { - val (init, tail) = byteString.splitAt(index) - if ((init endsWith "<") && (tail startsWith "/sc")) { - dirtyIndices(index + 1, index :: descIndices) - } else { - dirtyIndices(index + 1, descIndices) - } - } - } - - val indices = dirtyIndices(0, Nil) - - indices.headOption.fold(byteString) { head => - val builder = ByteString.newBuilder - builder ++= byteString.take(head) - - (indices :+ byteString.length).sliding(2).foreach { - case Seq(start, end) => - builder += ' ' - builder ++= byteString.slice(start, end) - case Seq(_) => // Should not match; sliding on at least 2 elements - assert(indices.nonEmpty, s"Indices should have been nonEmpty: $indices") - } - builder.result - } - } - - val sanitizeRequestEntity: Directive0 = { - mapRequest(request => request.mapEntity(entity => entity.transformDataBytes(Flow.fromFunction(escapeScriptTags)))) - } - - val paginated: Directive1[Pagination] = - parameters(("pageSize".as[Int] ? 100, "pageNumber".as[Int] ? 1)).as(Pagination) - - private def extractPagination(pageSizeOpt: Option[Int], pageNumberOpt: Option[Int]): Option[Pagination] = - (pageSizeOpt, pageNumberOpt) match { - case (Some(size), Some(number)) => Option(Pagination(size, number)) - case (None, None) => Option.empty[Pagination] - case (_, _) => throw new IllegalArgumentException("Pagination's parameters are incorrect") - } - - val optionalPagination: Directive1[Option[Pagination]] = - parameters(("pageSize".as[Int].?, "pageNumber".as[Int].?)).as(extractPagination) - - def paginationQuery(pagination: Pagination) = - Seq("pageNumber" -> pagination.pageNumber.toString, "pageSize" -> pagination.pageSize.toString) - - def completeWithPagination[T](handler: Option[Pagination] => Future[ListResponse[T]])( - implicit marshaller: ToEntityMarshaller[Seq[T]]): Route = { - optionalPagination { pagination => - onSuccess(handler(pagination)) { - case ListResponse(resultPart, ListResponse.Meta(count, _, pageSize)) => - val pageCount = if (pageSize == 0) 0 else (count / pageSize) + (if (count % pageSize == 0) 0 else 1) - val headers = List( - RawHeader(ContextHeaders.ResourceCount, count.toString), - RawHeader(ContextHeaders.PageCount, pageCount.toString)) - - respondWithHeaders(headers)(complete(ToResponseMarshallable(resultPart))) - } - } - } - - private def extractSorting(sortingString: Option[String]): Sorting = { - val sortingFields = sortingString.fold(Seq.empty[SortingField])( - _.split(",") - .filter(_.length > 0) - .map { sortingParam => - if (sortingParam.startsWith("-")) { - SortingField(sortingParam.substring(1), SortingOrder.Desc) - } else { - val fieldName = if (sortingParam.startsWith("+")) sortingParam.substring(1) else sortingParam - SortingField(fieldName, SortingOrder.Asc) - } - } - .toSeq) - - Sorting(sortingFields) - } - - val sorting: Directive1[Sorting] = parameter("sort".as[String].?).as(extractSorting) - - def sortingQuery(sorting: Sorting): Seq[(String, String)] = { - val sortingString = sorting.sortingFields - .map { sortingField => - sortingField.sortingOrder match { - case SortingOrder.Asc => sortingField.name - case SortingOrder.Desc => s"-${sortingField.name}" - } - } - .mkString(",") - Seq("sort" -> sortingString) - } -} diff --git a/src/main/scala/xyz/driver/core/rest/serviceDiscovery.scala b/src/main/scala/xyz/driver/core/rest/serviceDiscovery.scala deleted file mode 100644 index 55f1a2e..0000000 --- a/src/main/scala/xyz/driver/core/rest/serviceDiscovery.scala +++ /dev/null @@ -1,24 +0,0 @@ -package xyz.driver.core.rest - -import xyz.driver.core.Name - -trait ServiceDiscovery { - - def discover[T <: Service](serviceName: Name[Service]): T -} - -trait SavingUsedServiceDiscovery { - private val usedServices = new scala.collection.mutable.HashSet[String]() - - def saveServiceUsage(serviceName: Name[Service]): Unit = usedServices.synchronized { - usedServices += serviceName.value - } - - def getUsedServices: Set[String] = usedServices.synchronized { usedServices.toSet } -} - -class NoServiceDiscovery extends ServiceDiscovery with SavingUsedServiceDiscovery { - - def discover[T <: Service](serviceName: Name[Service]): T = - throw new IllegalArgumentException(s"Service with name $serviceName is unknown") -} diff --git a/src/main/scala/xyz/driver/core/rest/serviceRequestContext.scala b/src/main/scala/xyz/driver/core/rest/serviceRequestContext.scala deleted file mode 100644 index d2e4bc3..0000000 --- a/src/main/scala/xyz/driver/core/rest/serviceRequestContext.scala +++ /dev/null @@ -1,87 +0,0 @@ -package xyz.driver.core.rest - -import java.net.InetAddress - -import xyz.driver.core.auth.{AuthToken, PermissionsToken, User} -import xyz.driver.core.generators -import scalaz.Scalaz.{mapEqual, stringInstance} -import scalaz.syntax.equal._ -import xyz.driver.core.reporting.SpanContext -import xyz.driver.core.rest.auth.AuthProvider -import xyz.driver.core.rest.headers.Traceparent - -import scala.util.Try - -class ServiceRequestContext( - val trackingId: String = generators.nextUuid().toString, - val originatingIp: Option[InetAddress] = None, - val contextHeaders: Map[String, String] = Map.empty[String, String]) { - def authToken: Option[AuthToken] = - contextHeaders.get(AuthProvider.AuthenticationTokenHeader).map(AuthToken.apply) - - def permissionsToken: Option[PermissionsToken] = - contextHeaders.get(AuthProvider.PermissionsTokenHeader).map(PermissionsToken.apply) - - def withAuthToken(authToken: AuthToken): ServiceRequestContext = - new ServiceRequestContext( - trackingId, - originatingIp, - contextHeaders.updated(AuthProvider.AuthenticationTokenHeader, authToken.value) - ) - - def withAuthenticatedUser[U <: User](authToken: AuthToken, user: U): AuthorizedServiceRequestContext[U] = - new AuthorizedServiceRequestContext( - trackingId, - originatingIp, - contextHeaders.updated(AuthProvider.AuthenticationTokenHeader, authToken.value), - user - ) - - override def hashCode(): Int = - Seq[Any](trackingId, originatingIp, contextHeaders) - .foldLeft(31)((result, obj) => 31 * result + obj.hashCode()) - - override def equals(obj: Any): Boolean = obj match { - case ctx: ServiceRequestContext => - trackingId === ctx.trackingId && - originatingIp == originatingIp && - contextHeaders === ctx.contextHeaders - case _ => false - } - - def spanContext: SpanContext = { - val validHeader = Try { - contextHeaders(Traceparent.name) - }.flatMap { value => - Traceparent.parse(value) - } - validHeader.map(_.spanContext).getOrElse(SpanContext.fresh()) - } - - override def toString: String = s"ServiceRequestContext($trackingId, $contextHeaders)" -} - -class AuthorizedServiceRequestContext[U <: User]( - override val trackingId: String = generators.nextUuid().toString, - override val originatingIp: Option[InetAddress] = None, - override val contextHeaders: Map[String, String] = Map.empty[String, String], - val authenticatedUser: U) - extends ServiceRequestContext { - - def withPermissionsToken(permissionsToken: PermissionsToken): AuthorizedServiceRequestContext[U] = - new AuthorizedServiceRequestContext[U]( - trackingId, - originatingIp, - contextHeaders.updated(AuthProvider.PermissionsTokenHeader, permissionsToken.value), - authenticatedUser) - - override def hashCode(): Int = 31 * super.hashCode() + authenticatedUser.hashCode() - - override def equals(obj: Any): Boolean = obj match { - case ctx: AuthorizedServiceRequestContext[U] => super.equals(ctx) && ctx.authenticatedUser == authenticatedUser - case _ => false - } - - override def toString: String = - s"AuthorizedServiceRequestContext($trackingId, $contextHeaders, $authenticatedUser)" -} diff --git a/src/test/scala/xyz/driver/core/AuthTest.scala b/src/test/scala/xyz/driver/core/AuthTest.scala deleted file mode 100644 index 2e772fb..0000000 --- a/src/test/scala/xyz/driver/core/AuthTest.scala +++ /dev/null @@ -1,165 +0,0 @@ -package xyz.driver.core - -import akka.http.scaladsl.model.headers.{ - HttpChallenges, - OAuth2BearerToken, - RawHeader, - Authorization => AkkaAuthorization -} -import akka.http.scaladsl.server.Directives._ -import akka.http.scaladsl.server._ -import akka.http.scaladsl.testkit.ScalatestRouteTest -import org.scalatest.{FlatSpec, Matchers} -import pdi.jwt.{Jwt, JwtAlgorithm} -import xyz.driver.core.auth._ -import xyz.driver.core.domain.Email -import xyz.driver.core.logging._ -import xyz.driver.core.rest._ -import xyz.driver.core.rest.auth._ -import xyz.driver.core.time.Time - -import scala.concurrent.Future -import scalaz.OptionT - -class AuthTest extends FlatSpec with Matchers with ScalatestRouteTest { - - case object TestRoleAllowedPermission extends Permission - case object TestRoleAllowedByTokenPermission extends Permission - case object TestRoleNotAllowedPermission extends Permission - - val TestRole = Role(Id("1"), Name("testRole")) - - val (publicKey, privateKey) = { - import java.security.KeyPairGenerator - - val keygen = KeyPairGenerator.getInstance("RSA") - keygen.initialize(2048) - - val keyPair = keygen.generateKeyPair() - (keyPair.getPublic, keyPair.getPrivate) - } - - val basicAuthorization: Authorization[User] = new Authorization[User] { - - override def userHasPermissions(user: User, permissions: Seq[Permission])( - implicit ctx: ServiceRequestContext): Future[AuthorizationResult] = { - val authorized = permissions.map(p => p -> (p === TestRoleAllowedPermission)).toMap - Future.successful(AuthorizationResult(authorized, ctx.permissionsToken)) - } - } - - val tokenIssuer = "users" - val tokenAuthorization = new CachedTokenAuthorization[User](publicKey, tokenIssuer) - - val authorization = new ChainedAuthorization[User](tokenAuthorization, basicAuthorization) - - val authStatusService = new AuthProvider[User](authorization, NoLogger) { - override def authenticatedUser(implicit ctx: ServiceRequestContext): OptionT[Future, User] = - OptionT.optionT[Future] { - if (ctx.contextHeaders.keySet.contains(AuthProvider.AuthenticationTokenHeader)) { - Future.successful( - Some( - AuthTokenUserInfo( - Id[User]("1"), - Email("foo", "bar"), - emailVerified = true, - audience = "driver", - roles = Set(TestRole), - expirationTime = Time(1000000L) - ))) - } else { - Future.successful(Option.empty[User]) - } - } - } - - import authStatusService._ - - "'authorize' directive" should "throw error if auth token is not in the request" in { - - Get("/naive/attempt") ~> - authorize(TestRoleAllowedPermission) { user => - complete("Never going to be here") - } ~> - check { - // handled shouldBe false - rejections should contain( - AuthenticationFailedRejection( - AuthenticationFailedRejection.CredentialsMissing, - HttpChallenges.oAuth2(authStatusService.realm))) - } - } - - it should "throw error if authorized user does not have the requested permission" in { - - val referenceAuthToken = AuthToken("I am a test role's token") - val referenceAuthHeader = AkkaAuthorization(OAuth2BearerToken(referenceAuthToken.value)) - - Post("/administration/attempt").addHeader( - referenceAuthHeader - ) ~> - authorize(TestRoleNotAllowedPermission) { user => - complete("Never going to get here") - } ~> - check { - handled shouldBe false - rejections should contain(AuthorizationFailedRejection) - } - } - - it should "pass and retrieve the token to client code, if token is in request and user has permission" in { - val referenceAuthToken = AuthToken("I am token") - val referenceAuthHeader = AkkaAuthorization(OAuth2BearerToken(referenceAuthToken.value)) - - Get("/valid/attempt/?a=2&b=5").addHeader( - referenceAuthHeader - ) ~> - authorize(TestRoleAllowedPermission) { ctx => - complete(s"Alright, user ${ctx.authenticatedUser.id} is authorized") - } ~> - check { - handled shouldBe true - responseAs[String] shouldBe "Alright, user 1 is authorized" - } - } - - it should "authenticate correctly even without the 'Bearer' prefix on the Authorization header" in { - val referenceAuthToken = AuthToken("unprefixed_token") - - Get("/valid/attempt/?a=2&b=5").addHeader( - RawHeader(ContextHeaders.AuthenticationTokenHeader, referenceAuthToken.value) - ) ~> - authorize(TestRoleAllowedPermission) { ctx => - complete(s"Alright, user ${ctx.authenticatedUser.id} is authorized") - } ~> - check { - handled shouldBe true - responseAs[String] shouldBe "Alright, user 1 is authorized" - } - } - - it should "authorize permission found in permissions token" in { - import spray.json._ - - val claim = JsObject( - Map( - "iss" -> JsString(tokenIssuer), - "sub" -> JsString("1"), - "permissions" -> JsObject(Map(TestRoleAllowedByTokenPermission.toString -> JsBoolean(true))) - )).prettyPrint - val permissionsToken = PermissionsToken(Jwt.encode(claim, privateKey, JwtAlgorithm.RS256)) - val referenceAuthToken = AuthToken("I am token") - val referenceAuthHeader = AkkaAuthorization(OAuth2BearerToken(referenceAuthToken.value)) - - Get("/alic/attempt/?a=2&b=5") - .addHeader(referenceAuthHeader) - .addHeader(RawHeader(AuthProvider.PermissionsTokenHeader, permissionsToken.value)) ~> - authorize(TestRoleAllowedByTokenPermission) { ctx => - complete(s"Alright, user ${ctx.authenticatedUser.id} is authorized by permissions token") - } ~> - check { - handled shouldBe true - responseAs[String] shouldBe "Alright, user 1 is authorized by permissions token" - } - } -} diff --git a/src/test/scala/xyz/driver/core/GeneratorsTest.scala b/src/test/scala/xyz/driver/core/GeneratorsTest.scala deleted file mode 100644 index 7e740a4..0000000 --- a/src/test/scala/xyz/driver/core/GeneratorsTest.scala +++ /dev/null @@ -1,264 +0,0 @@ -package xyz.driver.core - -import org.scalatest.{Assertions, FlatSpec, Matchers} - -import scala.collection.immutable.IndexedSeq - -class GeneratorsTest extends FlatSpec with Matchers with Assertions { - import generators._ - - "Generators" should "be able to generate com.drivergrp.core.Id identifiers" in { - - val generatedId1 = nextId[String]() - val generatedId2 = nextId[String]() - val generatedId3 = nextId[Long]() - - generatedId1.length should be >= 0 - generatedId2.length should be >= 0 - generatedId3.length should be >= 0 - generatedId1 should not be generatedId2 - generatedId2 should !==(generatedId3) - } - - it should "be able to generate com.drivergrp.core.Id identifiers with max value" in { - - val generatedLimitedId1 = nextId[String](5) - val generatedLimitedId2 = nextId[String](4) - val generatedLimitedId3 = nextId[Long](3) - - generatedLimitedId1.length should be >= 0 - generatedLimitedId1.length should be < 6 - generatedLimitedId2.length should be >= 0 - generatedLimitedId2.length should be < 5 - generatedLimitedId3.length should be >= 0 - generatedLimitedId3.length should be < 4 - generatedLimitedId1 should not be generatedLimitedId2 - generatedLimitedId2 should !==(generatedLimitedId3) - } - - it should "be able to generate com.drivergrp.core.Name names" in { - - Seq.fill(10)(nextName[String]()).distinct.size should be > 1 - nextName[String]().value.length should be >= 0 - - val fixedLengthName = nextName[String](10) - fixedLengthName.length should be <= 10 - assert(!fixedLengthName.value.exists(_.isControl)) - } - - it should "be able to generate com.drivergrp.core.NonEmptyName with non empty strings" in { - - assert(nextNonEmptyName[String]().value.value.nonEmpty) - } - - it should "be able to generate proper UUIDs" in { - - nextUuid() should not be nextUuid() - nextUuid().toString.length should be(36) - } - - it should "be able to generate new Revisions" in { - - nextRevision[String]() should not be nextRevision[String]() - nextRevision[String]().id.length should be > 0 - } - - it should "be able to generate strings" in { - - nextString() should not be nextString() - nextString().length should be >= 0 - - val fixedLengthString = nextString(20) - fixedLengthString.length should be <= 20 - assert(!fixedLengthString.exists(_.isControl)) - } - - it should "be able to generate strings non-empty strings whic are non empty" in { - - assert(nextNonEmptyString().value.nonEmpty) - } - - it should "be able to generate options which are sometimes have values and sometimes not" in { - - val generatedOption = nextOption("2") - - generatedOption should not contain "1" - assert(generatedOption === Some("2") || generatedOption === None) - } - - it should "be able to generate a pair of two generated values" in { - - val constantPair = nextPair("foo", 1L) - constantPair._1 should be("foo") - constantPair._2 should be(1L) - - val generatedPair = nextPair(nextId[Int](), nextName[Int]()) - - generatedPair._1.length should be > 0 - generatedPair._2.length should be > 0 - - nextPair(nextId[Int](), nextName[Int]()) should not be - nextPair(nextId[Int](), nextName[Int]()) - } - - it should "be able to generate a triad of two generated values" in { - - val constantTriad = nextTriad("foo", "bar", 1L) - constantTriad._1 should be("foo") - constantTriad._2 should be("bar") - constantTriad._3 should be(1L) - - val generatedTriad = nextTriad(nextId[Int](), nextName[Int](), nextBigDecimal()) - - generatedTriad._1.length should be > 0 - generatedTriad._2.length should be > 0 - generatedTriad._3 should be >= BigDecimal(0.00) - - nextTriad(nextId[Int](), nextName[Int](), nextBigDecimal()) should not be - nextTriad(nextId[Int](), nextName[Int](), nextBigDecimal()) - } - - it should "be able to generate a time value" in { - - val generatedTime = nextTime() - val currentTime = System.currentTimeMillis() - - generatedTime.millis should be >= 0L - generatedTime.millis should be <= currentTime - } - - it should "be able to generate a time range value" in { - - val generatedTimeRange = nextTimeRange() - val currentTime = System.currentTimeMillis() - - generatedTimeRange.start.millis should be >= 0L - generatedTimeRange.start.millis should be <= currentTime - generatedTimeRange.end.millis should be >= 0L - generatedTimeRange.end.millis should be <= currentTime - generatedTimeRange.start.millis should be <= generatedTimeRange.end.millis - } - - it should "be able to generate a BigDecimal value" in { - - val defaultGeneratedBigDecimal = nextBigDecimal() - - defaultGeneratedBigDecimal should be >= BigDecimal(0.00) - defaultGeneratedBigDecimal should be <= BigDecimal(1000000.00) - defaultGeneratedBigDecimal.precision should be(2) - - val unitIntervalBigDecimal = nextBigDecimal(1.00, 8) - - unitIntervalBigDecimal should be >= BigDecimal(0.00) - unitIntervalBigDecimal should be <= BigDecimal(1.00) - unitIntervalBigDecimal.precision should be(8) - } - - it should "be able to generate a specific value from a set of values" in { - - val possibleOptions = Set(1, 3, 5, 123, 0, 9) - - val pick1 = generators.oneOf(possibleOptions) - val pick2 = generators.oneOf(possibleOptions) - val pick3 = generators.oneOf(possibleOptions) - - possibleOptions should contain(pick1) - possibleOptions should contain(pick2) - possibleOptions should contain(pick3) - - val pick4 = generators.oneOf(1, 3, 5, 123, 0, 9) - val pick5 = generators.oneOf(1, 3, 5, 123, 0, 9) - val pick6 = generators.oneOf(1, 3, 5, 123, 0, 9) - - possibleOptions should contain(pick4) - possibleOptions should contain(pick5) - possibleOptions should contain(pick6) - - Set(pick1, pick2, pick3, pick4, pick5, pick6).size should be >= 1 - } - - it should "be able to generate a specific value from an enumeratum enum" in { - - import enumeratum._ - sealed trait TestEnumValue extends EnumEntry - object TestEnum extends Enum[TestEnumValue] { - case object Value1 extends TestEnumValue - case object Value2 extends TestEnumValue - case object Value3 extends TestEnumValue - case object Value4 extends TestEnumValue - val values: IndexedSeq[TestEnumValue] = findValues - } - - val picks = (1 to 100).map(_ => generators.oneOf(TestEnum)) - - TestEnum.values should contain allElementsOf picks - picks.toSet.size should be >= 1 - } - - it should "be able to generate array with values generated by generators" in { - - val arrayOfTimes = arrayOf(nextTime(), 16) - arrayOfTimes.length should be <= 16 - - val arrayOfBigDecimals = arrayOf(nextBigDecimal(), 8) - arrayOfBigDecimals.length should be <= 8 - } - - it should "be able to generate seq with values generated by generators" in { - - val seqOfTimes = seqOf(nextTime(), 16) - seqOfTimes.size should be <= 16 - - val seqOfBigDecimals = seqOf(nextBigDecimal(), 8) - seqOfBigDecimals.size should be <= 8 - } - - it should "be able to generate vector with values generated by generators" in { - - val vectorOfTimes = vectorOf(nextTime(), 16) - vectorOfTimes.size should be <= 16 - - val vectorOfStrings = seqOf(nextString(), 8) - vectorOfStrings.size should be <= 8 - } - - it should "be able to generate list with values generated by generators" in { - - val listOfTimes = listOf(nextTime(), 16) - listOfTimes.size should be <= 16 - - val listOfBigDecimals = seqOf(nextBigDecimal(), 8) - listOfBigDecimals.size should be <= 8 - } - - it should "be able to generate set with values generated by generators" in { - - val setOfTimes = vectorOf(nextTime(), 16) - setOfTimes.size should be <= 16 - - val setOfBigDecimals = seqOf(nextBigDecimal(), 8) - setOfBigDecimals.size should be <= 8 - } - - it should "be able to generate maps with keys and values generated by generators" in { - - val generatedConstantMap = mapOf("key", 123, 10) - generatedConstantMap.size should be <= 1 - assert(generatedConstantMap.keys.forall(_ == "key")) - assert(generatedConstantMap.values.forall(_ == 123)) - - val generatedMap = mapOf(nextString(10), nextBigDecimal(), 10) - assert(generatedMap.keys.forall(_.length <= 10)) - assert(generatedMap.values.forall(_ >= BigDecimal(0.00))) - } - - it should "compose deeply" in { - - val generatedNestedMap = mapOf(nextString(10), nextPair(nextBigDecimal(), nextOption(123)), 10) - - generatedNestedMap.size should be <= 10 - generatedNestedMap.keySet.size should be <= 10 - generatedNestedMap.values.size should be <= 10 - assert(generatedNestedMap.values.forall(value => !value._2.exists(_ != 123))) - } -} diff --git a/src/test/scala/xyz/driver/core/JsonTest.scala b/src/test/scala/xyz/driver/core/JsonTest.scala deleted file mode 100644 index fd693f9..0000000 --- a/src/test/scala/xyz/driver/core/JsonTest.scala +++ /dev/null @@ -1,521 +0,0 @@ -package xyz.driver.core - -import java.net.InetAddress -import java.time.{Instant, LocalDate} - -import akka.http.scaladsl.model.Uri -import akka.http.scaladsl.server.PathMatcher -import akka.http.scaladsl.server.PathMatcher.Matched -import com.neovisionaries.i18n.{CountryCode, CurrencyCode} -import enumeratum._ -import eu.timepit.refined.collection.NonEmpty -import eu.timepit.refined.numeric.Positive -import eu.timepit.refined.refineMV -import org.scalatest.{Inspectors, Matchers, WordSpec} -import spray.json._ -import xyz.driver.core.TestTypes.CustomGADT -import xyz.driver.core.auth.AuthCredentials -import xyz.driver.core.domain.{Email, PhoneNumber} -import xyz.driver.core.json._ -import xyz.driver.core.json.enumeratum.HasJsonFormat -import xyz.driver.core.tagging._ -import xyz.driver.core.time.provider.SystemTimeProvider -import xyz.driver.core.time.{Time, TimeOfDay} - -import scala.collection.immutable.IndexedSeq -import scala.language.postfixOps - -class JsonTest extends WordSpec with Matchers with Inspectors { - import DefaultJsonProtocol._ - - "Json format for Id" should { - "read and write correct JSON" in { - - val referenceId = Id[String]("1312-34A") - - val writtenJson = json.idFormat.write(referenceId) - writtenJson.prettyPrint should be("\"1312-34A\"") - - val parsedId = json.idFormat.read(writtenJson) - parsedId should be(referenceId) - } - } - - "Json format for @@" should { - "read and write correct JSON" in { - trait Irrelevant - val reference = Id[JsonTest]("SomeID").tagged[Irrelevant] - - val format = json.taggedFormat[Id[JsonTest], Irrelevant] - - val writtenJson = format.write(reference) - writtenJson shouldBe JsString("SomeID") - - val parsedId: Id[JsonTest] @@ Irrelevant = format.read(writtenJson) - parsedId shouldBe reference - } - - "read and write correct JSON when there's an implicit conversion defined" in { - val input = " some string " - - JsString(input).convertTo[String @@ Trimmed] shouldBe input.trim() - - val trimmed: String @@ Trimmed = input - trimmed.toJson shouldBe JsString(trimmed) - } - } - - "Json format for Name" should { - "read and write correct JSON" in { - - val referenceName = Name[String]("Homer") - - val writtenJson = json.nameFormat.write(referenceName) - writtenJson.prettyPrint should be("\"Homer\"") - - val parsedName = json.nameFormat.read(writtenJson) - parsedName should be(referenceName) - } - - "read and write correct JSON for Name @@ Trimmed" in { - trait Irrelevant - JsString(" some name ").convertTo[Name[Irrelevant] @@ Trimmed] shouldBe Name[Irrelevant]("some name") - - val trimmed: Name[Irrelevant] @@ Trimmed = Name(" some name ") - trimmed.toJson shouldBe JsString("some name") - } - } - - "Json format for NonEmptyName" should { - "read and write correct JSON" in { - - val jsonFormat = json.nonEmptyNameFormat[String] - - val referenceNonEmptyName = NonEmptyName[String](refineMV[NonEmpty]("Homer")) - - val writtenJson = jsonFormat.write(referenceNonEmptyName) - writtenJson.prettyPrint should be("\"Homer\"") - - val parsedNonEmptyName = jsonFormat.read(writtenJson) - parsedNonEmptyName should be(referenceNonEmptyName) - } - } - - "Json format for Time" should { - "read and write correct JSON" in { - - val referenceTime = new SystemTimeProvider().currentTime() - - val writtenJson = json.timeFormat.write(referenceTime) - writtenJson.prettyPrint should be("{\n \"timestamp\": " + referenceTime.millis + "\n}") - - val parsedTime = json.timeFormat.read(writtenJson) - parsedTime should be(referenceTime) - } - - "read from inputs compatible with Instant" in { - val referenceTime = new SystemTimeProvider().currentTime() - - val jsons = Seq(JsNumber(referenceTime.millis), JsString(Instant.ofEpochMilli(referenceTime.millis).toString)) - - forAll(jsons) { json => - json.convertTo[Time] shouldBe referenceTime - } - } - } - - "Json format for TimeOfDay" should { - "read and write correct JSON" in { - val utcTimeZone = java.util.TimeZone.getTimeZone("UTC") - val referenceTimeOfDay = TimeOfDay.parseTimeString(utcTimeZone)("08:00:00") - val writtenJson = json.timeOfDayFormat.write(referenceTimeOfDay) - writtenJson should be("""{"localTime":"08:00:00","timeZone":"UTC"}""".parseJson) - val parsed = json.timeOfDayFormat.read(writtenJson) - parsed should be(referenceTimeOfDay) - } - } - - "Json format for Date" should { - "read and write correct JSON" in { - import date._ - - val referenceDate = Date(1941, Month.DECEMBER, 7) - - val writtenJson = json.dateFormat.write(referenceDate) - writtenJson.prettyPrint should be("\"1941-12-07\"") - - val parsedDate = json.dateFormat.read(writtenJson) - parsedDate should be(referenceDate) - } - } - - "Json format for java.time.Instant" should { - - val isoString = "2018-08-08T08:08:08.888Z" - val instant = Instant.parse(isoString) - - "read correct JSON when value is an epoch milli number" in { - JsNumber(instant.toEpochMilli).convertTo[Instant] shouldBe instant - } - - "read correct JSON when value is an ISO timestamp string" in { - JsString(isoString).convertTo[Instant] shouldBe instant - } - - "read correct JSON when value is an object with nested 'timestamp'/millis field" in { - val json = JsObject( - "timestamp" -> JsNumber(instant.toEpochMilli) - ) - - json.convertTo[Instant] shouldBe instant - } - - "write correct JSON" in { - instant.toJson shouldBe JsString(isoString) - } - } - - "Path matcher for Instant" should { - - val isoString = "2018-08-08T08:08:08.888Z" - val instant = Instant.parse(isoString) - - val matcher = PathMatcher("foo") / InstantInPath / - - "read instant from millis" in { - matcher(Uri.Path("foo") / ("+" + instant.toEpochMilli) / "bar") shouldBe Matched(Uri.Path("bar"), Tuple1(instant)) - } - - "read instant from ISO timestamp string" in { - matcher(Uri.Path("foo") / isoString / "bar") shouldBe Matched(Uri.Path("bar"), Tuple1(instant)) - } - } - - "Json format for java.time.LocalDate" should { - - "read and write correct JSON" in { - val dateString = "2018-08-08" - val date = LocalDate.parse(dateString) - - date.toJson shouldBe JsString(dateString) - JsString(dateString).convertTo[LocalDate] shouldBe date - } - } - - "Json format for Revision" should { - "read and write correct JSON" in { - - val referenceRevision = Revision[String]("037e2ec0-8901-44ac-8e53-6d39f6479db4") - - val writtenJson = json.revisionFormat.write(referenceRevision) - writtenJson.prettyPrint should be("\"" + referenceRevision.id + "\"") - - val parsedRevision = json.revisionFormat.read(writtenJson) - parsedRevision should be(referenceRevision) - } - } - - "Json format for Email" should { - "read and write correct JSON" in { - - val referenceEmail = Email("test", "drivergrp.com") - - val writtenJson = json.emailFormat.write(referenceEmail) - writtenJson should be("\"test@drivergrp.com\"".parseJson) - - val parsedEmail = json.emailFormat.read(writtenJson) - parsedEmail should be(referenceEmail) - } - } - - "Json format for PhoneNumber" should { - "read and write correct JSON" in { - - val referencePhoneNumber = PhoneNumber("1", "4243039608") - - val writtenJson = json.phoneNumberFormat.write(referencePhoneNumber) - writtenJson should be("""{"countryCode":"1","number":"4243039608"}""".parseJson) - - val parsedPhoneNumber = json.phoneNumberFormat.read(writtenJson) - parsedPhoneNumber should be(referencePhoneNumber) - } - - "reject an invalid phone number" in { - val phoneJson = """{"countryCode":"1","number":"111-111-1113"}""".parseJson - - intercept[DeserializationException] { - 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 { - "read and write correct JSON" in { - - sealed trait EnumVal - case object Val1 extends EnumVal - case object Val2 extends EnumVal - case object Val3 extends EnumVal - - val format = new EnumJsonFormat[EnumVal]("a" -> Val1, "b" -> Val2, "c" -> Val3) - - val referenceEnumValue1 = Val2 - val referenceEnumValue2 = Val3 - - val writtenJson1 = format.write(referenceEnumValue1) - writtenJson1.prettyPrint should be("\"b\"") - - val writtenJson2 = format.write(referenceEnumValue2) - writtenJson2.prettyPrint should be("\"c\"") - - val parsedEnumValue1 = format.read(writtenJson1) - val parsedEnumValue2 = format.read(writtenJson2) - - parsedEnumValue1 should be(referenceEnumValue1) - parsedEnumValue2 should be(referenceEnumValue2) - } - } - - "Json format for Enums (external)" should { - "read and write correct JSON" in { - - sealed trait MyEnum extends EnumEntry - object MyEnum extends Enum[MyEnum] { - case object Val1 extends MyEnum - case object `Val 2` extends MyEnum - case object `Val/3` extends MyEnum - - val values: IndexedSeq[MyEnum] = findValues - } - - val format = new enumeratum.EnumJsonFormat(MyEnum) - - val referenceEnumValue1 = MyEnum.`Val 2` - val referenceEnumValue2 = MyEnum.`Val/3` - - val writtenJson1 = format.write(referenceEnumValue1) - writtenJson1 shouldBe JsString("Val 2") - - val writtenJson2 = format.write(referenceEnumValue2) - writtenJson2 shouldBe JsString("Val/3") - - val parsedEnumValue1 = format.read(writtenJson1) - val parsedEnumValue2 = format.read(writtenJson2) - - parsedEnumValue1 shouldBe referenceEnumValue1 - parsedEnumValue2 shouldBe referenceEnumValue2 - - intercept[DeserializationException] { - format.read(JsString("Val4")) - }.getMessage shouldBe "Unexpected value Val4. Expected one of: [Val1, Val 2, Val/3]" - } - } - - "Json format for Enums (automatic)" should { - "read and write correct JSON and not require import" in { - - sealed trait MyEnum extends EnumEntry - object MyEnum extends Enum[MyEnum] with HasJsonFormat[MyEnum] { - case object Val1 extends MyEnum - case object `Val 2` extends MyEnum - case object `Val/3` extends MyEnum - - val values: IndexedSeq[MyEnum] = findValues - } - - val referenceEnumValue1: MyEnum = MyEnum.`Val 2` - val referenceEnumValue2: MyEnum = MyEnum.`Val/3` - - val writtenJson1 = referenceEnumValue1.toJson - writtenJson1 shouldBe JsString("Val 2") - - val writtenJson2 = referenceEnumValue2.toJson - writtenJson2 shouldBe JsString("Val/3") - - import spray.json._ - - val parsedEnumValue1 = writtenJson1.prettyPrint.parseJson.convertTo[MyEnum] - val parsedEnumValue2 = writtenJson2.prettyPrint.parseJson.convertTo[MyEnum] - - parsedEnumValue1 should be(referenceEnumValue1) - parsedEnumValue2 should be(referenceEnumValue2) - - intercept[DeserializationException] { - JsString("Val4").convertTo[MyEnum] - }.getMessage shouldBe "Unexpected value Val4. Expected one of: [Val1, Val 2, Val/3]" - } - } - - // Should be defined outside of case to have a TypeTag - case class CustomWrapperClass(value: Int) - - "Json format for Value classes" should { - "read and write correct JSON" in { - - val format = new ValueClassFormat[CustomWrapperClass](v => BigDecimal(v.value), d => CustomWrapperClass(d.toInt)) - - val referenceValue1 = CustomWrapperClass(-2) - val referenceValue2 = CustomWrapperClass(10) - - val writtenJson1 = format.write(referenceValue1) - writtenJson1.prettyPrint should be("-2") - - val writtenJson2 = format.write(referenceValue2) - writtenJson2.prettyPrint should be("10") - - val parsedValue1 = format.read(writtenJson1) - val parsedValue2 = format.read(writtenJson2) - - parsedValue1 should be(referenceValue1) - parsedValue2 should be(referenceValue2) - } - } - - "Json format for classes GADT" should { - "read and write correct JSON" in { - - import CustomGADT._ - import DefaultJsonProtocol._ - implicit val case1Format = jsonFormat1(GadtCase1) - implicit val case2Format = jsonFormat1(GadtCase2) - implicit val case3Format = jsonFormat1(GadtCase3) - - val format = GadtJsonFormat.create[CustomGADT]("gadtTypeField") { - case _: CustomGADT.GadtCase1 => "case1" - case _: CustomGADT.GadtCase2 => "case2" - case _: CustomGADT.GadtCase3 => "case3" - } { - case "case1" => case1Format - case "case2" => case2Format - case "case3" => case3Format - } - - val referenceValue1 = CustomGADT.GadtCase1("4") - val referenceValue2 = CustomGADT.GadtCase2("Hi!") - - val writtenJson1 = format.write(referenceValue1) - writtenJson1 should be("{\n \"field\": \"4\",\n\"gadtTypeField\": \"case1\"\n}".parseJson) - - val writtenJson2 = format.write(referenceValue2) - writtenJson2 should be("{\"field\":\"Hi!\",\"gadtTypeField\":\"case2\"}".parseJson) - - val parsedValue1 = format.read(writtenJson1) - val parsedValue2 = format.read(writtenJson2) - - parsedValue1 should be(referenceValue1) - parsedValue2 should be(referenceValue2) - } - } - - "Json format for a Refined value" should { - "read and write correct JSON" in { - - val jsonFormat = json.refinedJsonFormat[Int, Positive] - - val referenceRefinedNumber = refineMV[Positive](42) - - val writtenJson = jsonFormat.write(referenceRefinedNumber) - writtenJson should be("42".parseJson) - - val parsedRefinedNumber = jsonFormat.read(writtenJson) - parsedRefinedNumber should be(referenceRefinedNumber) - } - } - - "InetAddress format" should { - "read and write correct JSON" in { - val address = InetAddress.getByName("127.0.0.1") - val json = inetAddressFormat.write(address) - - json shouldBe JsString("127.0.0.1") - - val parsed = inetAddressFormat.read(json) - parsed shouldBe address - } - - "throw a DeserializationException for an invalid IP Address" in { - assertThrows[DeserializationException] { - val invalidAddress = JsString("foobar:") - inetAddressFormat.read(invalidAddress) - } - } - } - - "AuthCredentials format" should { - "read and write correct JSON" in { - val email = Email("someone", "noehere.com") - val phoneId = PhoneNumber.parse("1 207 8675309") - val password = "nopassword" - - phoneId.isDefined should be(true) // test this real quick - - val emailAuth = AuthCredentials(email.toString, password) - val pnAuth = AuthCredentials(phoneId.get.toString, password) - - val emailWritten = authCredentialsFormat.write(emailAuth) - emailWritten should be("""{"identifier":"someone@noehere.com","password":"nopassword"}""".parseJson) - - val phoneWritten = authCredentialsFormat.write(pnAuth) - phoneWritten should be("""{"identifier":"+1 2078675309","password":"nopassword"}""".parseJson) - - val identifierEmailParsed = - authCredentialsFormat.read("""{"identifier":"someone@nowhere.com","password":"nopassword"}""".parseJson) - var written = authCredentialsFormat.write(identifierEmailParsed) - written should be("{\"identifier\":\"someone@nowhere.com\",\"password\":\"nopassword\"}".parseJson) - - val emailEmailParsed = - authCredentialsFormat.read("""{"email":"someone@nowhere.com","password":"nopassword"}""".parseJson) - written = authCredentialsFormat.write(emailEmailParsed) - written should be("{\"identifier\":\"someone@nowhere.com\",\"password\":\"nopassword\"}".parseJson) - - } - } - - "CountryCode format" should { - "read and write correct JSON" in { - val samples = Seq( - "US" -> CountryCode.US, - "CN" -> CountryCode.CN, - "AT" -> CountryCode.AT - ) - - forAll(samples) { - case (serialized, enumValue) => - countryCodeFormat.write(enumValue) shouldBe JsString(serialized) - countryCodeFormat.read(JsString(serialized)) shouldBe enumValue - } - } - } - - "CurrencyCode format" should { - "read and write correct JSON" in { - val samples = Seq( - "USD" -> CurrencyCode.USD, - "CNY" -> CurrencyCode.CNY, - "EUR" -> CurrencyCode.EUR - ) - - forAll(samples) { - case (serialized, enumValue) => - currencyCodeFormat.write(enumValue) shouldBe JsString(serialized) - currencyCodeFormat.read(JsString(serialized)) shouldBe enumValue - } - } - } - -} diff --git a/src/test/scala/xyz/driver/core/TestTypes.scala b/src/test/scala/xyz/driver/core/TestTypes.scala deleted file mode 100644 index bb25deb..0000000 --- a/src/test/scala/xyz/driver/core/TestTypes.scala +++ /dev/null @@ -1,14 +0,0 @@ -package xyz.driver.core - -object TestTypes { - - sealed trait CustomGADT { - val field: String - } - - object CustomGADT { - final case class GadtCase1(field: String) extends CustomGADT - final case class GadtCase2(field: String) extends CustomGADT - final case class GadtCase3(field: String) extends CustomGADT - } -} diff --git a/src/test/scala/xyz/driver/core/rest/DriverAppTest.scala b/src/test/scala/xyz/driver/core/rest/DriverAppTest.scala deleted file mode 100644 index 324c8d8..0000000 --- a/src/test/scala/xyz/driver/core/rest/DriverAppTest.scala +++ /dev/null @@ -1,89 +0,0 @@ -package xyz.driver.core.rest - -import akka.http.scaladsl.model.headers._ -import akka.http.scaladsl.model.{HttpMethod, StatusCodes} -import akka.http.scaladsl.server.{Directives, Route} -import akka.http.scaladsl.testkit.ScalatestRouteTest -import com.typesafe.config.ConfigFactory -import org.scalatest.{AsyncFlatSpec, Matchers} -import xyz.driver.core.app.{DriverApp, SimpleModule} - -class DriverAppTest extends AsyncFlatSpec with ScalatestRouteTest with Matchers with Directives { - val config = ConfigFactory.parseString(""" - |application { - | cors { - | allowedOrigins: ["example.com"] - | } - |} - """.stripMargin).withFallback(ConfigFactory.load) - - val origin = Origin(HttpOrigin("https", Host("example.com"))) - val allowedOrigins = Set(HttpOrigin("https", Host("example.com"))) - val allowedMethods: collection.immutable.Seq[HttpMethod] = { - import akka.http.scaladsl.model.HttpMethods._ - collection.immutable.Seq(GET, PUT, POST, PATCH, DELETE, OPTIONS, TRACE) - } - - import scala.reflect.runtime.universe.typeOf - class TestApp(testRoute: Route) - extends DriverApp( - appName = "test-app", - version = "0.0.1", - gitHash = "deadb33f", - modules = Seq(new SimpleModule("test-module", theRoute = testRoute, routeType = typeOf[DriverApp])), - config = config, - log = xyz.driver.core.logging.NoLogger - ) - - it should "respond with the correct CORS headers for the swagger OPTIONS route" in { - val route = new TestApp(get(complete(StatusCodes.OK))) - Options(s"/api-docs/swagger.json").withHeaders(origin) ~> route.appRoute ~> check { - status shouldBe StatusCodes.OK - headers should contain(`Access-Control-Allow-Origin`(HttpOriginRange(allowedOrigins.toSeq: _*))) - header[`Access-Control-Allow-Methods`].get.methods should contain theSameElementsAs allowedMethods - } - } - - it should "respond with the correct CORS headers for the test route" in { - val route = new TestApp(get(complete(StatusCodes.OK))) - Get(s"/api/v1/test").withHeaders(origin) ~> route.appRoute ~> check { - status shouldBe StatusCodes.OK - headers should contain(`Access-Control-Allow-Origin`(HttpOriginRange(allowedOrigins.toSeq: _*))) - } - } - - it should "respond with the correct CORS headers for a concatenated route" in { - val route = new TestApp(get(complete(StatusCodes.OK)) ~ post(complete(StatusCodes.OK))) - Post(s"/api/v1/test").withHeaders(origin) ~> route.appRoute ~> check { - status shouldBe StatusCodes.OK - headers should contain(`Access-Control-Allow-Origin`(HttpOriginRange(allowedOrigins.toSeq: _*))) - } - } - - it should "allow subdomains of allowed origin suffixes" in { - val route = new TestApp(get(complete(StatusCodes.OK))) - Get(s"/api/v1/test") - .withHeaders(Origin(HttpOrigin("https", Host("foo.example.com")))) ~> route.appRoute ~> check { - status shouldBe StatusCodes.OK - headers should contain(`Access-Control-Allow-Origin`(HttpOrigin("https", Host("foo.example.com")))) - } - } - - it should "respond with default domains for invalid origins" in { - val route = new TestApp(get(complete(StatusCodes.OK))) - Get(s"/api/v1/test") - .withHeaders(Origin(HttpOrigin("https", Host("invalid.foo.bar.com")))) ~> route.appRoute ~> check { - status shouldBe StatusCodes.OK - headers should contain(`Access-Control-Allow-Origin`(HttpOriginRange.*)) - } - } - - it should "respond with Pragma and Cache-Control (no-cache) headers" in { - val route = new TestApp(get(complete(StatusCodes.OK))) - Get(s"/api/v1/test") ~> route.appRoute ~> check { - status shouldBe StatusCodes.OK - header("Pragma").map(_.value()) should contain("no-cache") - header[`Cache-Control`].map(_.value()) should contain("no-cache") - } - } -} diff --git a/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala b/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala deleted file mode 100644 index cc0019a..0000000 --- a/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala +++ /dev/null @@ -1,121 +0,0 @@ -package xyz.driver.core.rest - -import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport -import akka.http.scaladsl.model.StatusCodes -import akka.http.scaladsl.model.headers.Connection -import akka.http.scaladsl.server.Directives.{complete => akkaComplete} -import akka.http.scaladsl.server.{Directives, RejectionHandler, Route} -import akka.http.scaladsl.testkit.ScalatestRouteTest -import com.typesafe.scalalogging.Logger -import org.scalatest.{AsyncFlatSpec, Matchers} -import xyz.driver.core.json.serviceExceptionFormat -import xyz.driver.core.logging.NoLogger -import xyz.driver.core.rest.errors._ - -import scala.concurrent.Future - -class DriverRouteTest - extends AsyncFlatSpec with ScalatestRouteTest with SprayJsonSupport with Matchers with Directives { - class TestRoute(override val route: Route) extends DriverRoute { - override def log: Logger = NoLogger - } - - "DriverRoute" should "respond with 200 OK for a basic route" in { - val route = new TestRoute(akkaComplete(StatusCodes.OK)) - - Get("/api/v1/foo/bar") ~> route.routeWithDefaults ~> check { - handled shouldBe true - status shouldBe StatusCodes.OK - } - } - - it should "respond with a 401 for an InvalidInputException" in { - val route = new TestRoute(akkaComplete(Future.failed[String](InvalidInputException()))) - - Post("/api/v1/foo/bar") ~> route.routeWithDefaults ~> check { - handled shouldBe true - status shouldBe StatusCodes.BadRequest - responseAs[ServiceException] shouldBe InvalidInputException() - } - } - - it should "respond with a 403 for InvalidActionException" in { - val route = new TestRoute(akkaComplete(Future.failed[String](InvalidActionException()))) - - Post("/api/v1/foo/bar") ~> route.routeWithDefaults ~> check { - handled shouldBe true - status shouldBe StatusCodes.Forbidden - responseAs[ServiceException] shouldBe InvalidActionException() - } - } - - it should "respond with a 404 for ResourceNotFoundException" in { - val route = new TestRoute(akkaComplete(Future.failed[String](ResourceNotFoundException()))) - - Post("/api/v1/foo/bar") ~> route.routeWithDefaults ~> check { - handled shouldBe true - status shouldBe StatusCodes.NotFound - responseAs[ServiceException] shouldBe ResourceNotFoundException() - } - } - - it should "respond with a 500 for ExternalServiceException" in { - val error = ExternalServiceException("GET /api/v1/users/", "Permission denied", None) - val route = new TestRoute(akkaComplete(Future.failed[String](error))) - - Post("/api/v1/foo/bar") ~> route.routeWithDefaults ~> check { - handled shouldBe true - status shouldBe StatusCodes.InternalServerError - responseAs[ServiceException] shouldBe error - } - } - - it should "allow pass-through of external service exceptions" in { - val innerError = InvalidInputException() - val error = ExternalServiceException("GET /api/v1/users/", "Permission denied", Some(innerError)) - val future = Future.failed[String](error) - val route = new TestRoute(akkaComplete(future.passThroughExternalServiceException)) - - Post("/api/v1/foo/bar") ~> route.routeWithDefaults ~> check { - handled shouldBe true - status shouldBe StatusCodes.BadRequest - responseAs[ServiceException] shouldBe innerError - } - } - - it should "respond with a 503 for ExternalServiceTimeoutException" in { - val error = ExternalServiceTimeoutException("GET /api/v1/users/") - val route = new TestRoute(akkaComplete(Future.failed[String](error))) - - Post("/api/v1/foo/bar") ~> route.routeWithDefaults ~> check { - handled shouldBe true - status shouldBe StatusCodes.GatewayTimeout - responseAs[ServiceException] shouldBe error - } - } - - it should "respond with a 500 for DatabaseException" in { - val route = new TestRoute(akkaComplete(Future.failed[String](DatabaseException()))) - - Post("/api/v1/foo/bar") ~> route.routeWithDefaults ~> check { - handled shouldBe true - status shouldBe StatusCodes.InternalServerError - responseAs[ServiceException] shouldBe DatabaseException() - } - } - - it should "add a `Connection: close` header to avoid clashing with envoy's timeouts" in { - val rejectionHandler = RejectionHandler.newBuilder().handleNotFound(complete(StatusCodes.NotFound)).result() - val route = new TestRoute(handleRejections(rejectionHandler)((get & path("foo"))(complete("OK")))) - - Get("/foo") ~> route.routeWithDefaults ~> check { - status shouldBe StatusCodes.OK - headers should contain(Connection("close")) - } - - Get("/bar") ~> route.routeWithDefaults ~> check { - status shouldBe StatusCodes.NotFound - headers should contain(Connection("close")) - } - } -} diff --git a/src/test/scala/xyz/driver/core/rest/PatchDirectivesTest.scala b/src/test/scala/xyz/driver/core/rest/PatchDirectivesTest.scala deleted file mode 100644 index 987717d..0000000 --- a/src/test/scala/xyz/driver/core/rest/PatchDirectivesTest.scala +++ /dev/null @@ -1,101 +0,0 @@ -package xyz.driver.core.rest - -import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport -import akka.http.scaladsl.model._ -import akka.http.scaladsl.model.headers.`Content-Type` -import akka.http.scaladsl.server.{Directives, Route} -import akka.http.scaladsl.testkit.ScalatestRouteTest -import org.scalatest.{FlatSpec, Matchers} -import spray.json._ -import xyz.driver.core.{Id, Name} -import xyz.driver.core.json._ - -import scala.concurrent.Future - -class PatchDirectivesTest - extends FlatSpec with Matchers with ScalatestRouteTest with SprayJsonSupport with DefaultJsonProtocol - with Directives with PatchDirectives { - case class Bar(name: Name[Bar], size: Int) - case class Foo(id: Id[Foo], name: Name[Foo], rank: Int, bar: Option[Bar]) - implicit val barFormat: RootJsonFormat[Bar] = jsonFormat2(Bar) - implicit val fooFormat: RootJsonFormat[Foo] = jsonFormat4(Foo) - - val testFoo: Foo = Foo(Id("1"), Name(s"Foo"), 1, Some(Bar(Name("Bar"), 10))) - - def route(retrieve: => Future[Option[Foo]]): Route = - Route.seal(path("api" / "v1" / "foos" / IdInPath[Foo]) { fooId => - entity(as[Patchable[Foo]]) { fooPatchable => - mergePatch(fooPatchable, retrieve) { updatedFoo => - complete(updatedFoo) - } - } - }) - - val MergePatchContentType = ContentType(`application/merge-patch+json`) - val ContentTypeHeader = `Content-Type`(MergePatchContentType) - def jsonEntity(json: String, contentType: ContentType.NonBinary = MergePatchContentType): RequestEntity = - HttpEntity(contentType, json) - - "PatchSupport" should "allow partial updates to an existing object" in { - val fooRetrieve = Future.successful(Some(testFoo)) - - Patch("/api/v1/foos/1", jsonEntity("""{"rank": 4}""")) ~> route(fooRetrieve) ~> check { - handled shouldBe true - responseAs[Foo] shouldBe testFoo.copy(rank = 4) - } - } - - it should "merge deeply nested objects" in { - val fooRetrieve = Future.successful(Some(testFoo)) - - Patch("/api/v1/foos/1", jsonEntity("""{"rank": 4, "bar": {"name": "My Bar"}}""")) ~> route(fooRetrieve) ~> check { - handled shouldBe true - responseAs[Foo] shouldBe testFoo.copy(rank = 4, bar = Some(Bar(Name("My Bar"), 10))) - } - } - - it should "return a 404 if the object is not found" in { - val fooRetrieve = Future.successful(None) - - Patch("/api/v1/foos/1", jsonEntity("""{"rank": 4}""")) ~> route(fooRetrieve) ~> check { - handled shouldBe true - status shouldBe StatusCodes.NotFound - } - } - - it should "handle nulls on optional values correctly" in { - val fooRetrieve = Future.successful(Some(testFoo)) - - Patch("/api/v1/foos/1", jsonEntity("""{"bar": null}""")) ~> route(fooRetrieve) ~> check { - handled shouldBe true - responseAs[Foo] shouldBe testFoo.copy(bar = None) - } - } - - it should "handle optional values correctly when old value is null" in { - val fooRetrieve = Future.successful(Some(testFoo.copy(bar = None))) - - Patch("/api/v1/foos/1", jsonEntity("""{"bar": {"name": "My Bar","size":10}}""")) ~> route(fooRetrieve) ~> check { - handled shouldBe true - responseAs[Foo] shouldBe testFoo.copy(bar = Some(Bar(Name("My Bar"), 10))) - } - } - - it should "return a 400 for nulls on non-optional values" in { - val fooRetrieve = Future.successful(Some(testFoo)) - - Patch("/api/v1/foos/1", jsonEntity("""{"rank": null}""")) ~> route(fooRetrieve) ~> check { - handled shouldBe true - status shouldBe StatusCodes.BadRequest - } - } - - it should "return a 415 for incorrect Content-Type" in { - val fooRetrieve = Future.successful(Some(testFoo)) - - Patch("/api/v1/foos/1", jsonEntity("""{"rank": 4}""", ContentTypes.`application/json`)) ~> route(fooRetrieve) ~> check { - status shouldBe StatusCodes.UnsupportedMediaType - responseAs[String] should include("application/merge-patch+json") - } - } -} diff --git a/src/test/scala/xyz/driver/core/rest/RestTest.scala b/src/test/scala/xyz/driver/core/rest/RestTest.scala deleted file mode 100644 index 19e4ed1..0000000 --- a/src/test/scala/xyz/driver/core/rest/RestTest.scala +++ /dev/null @@ -1,151 +0,0 @@ -package xyz.driver.core.rest - -import akka.http.scaladsl.model.StatusCodes -import akka.http.scaladsl.server.{Directives, Route, ValidationRejection} -import akka.http.scaladsl.testkit.ScalatestRouteTest -import akka.util.ByteString -import org.scalatest.{Matchers, WordSpec} -import xyz.driver.core.rest - -import scala.concurrent.Future -import scala.util.Random - -class RestTest extends WordSpec with Matchers with ScalatestRouteTest with Directives { - "`escapeScriptTags` function" should { - "escape script tags properly" in { - val dirtyString = " - complete(StatusCodes.OK -> s"${paginated.pageNumber},${paginated.pageSize}") - } - "accept a pagination" in { - Get("/?pageNumber=2&pageSize=42") ~> route ~> check { - assert(status == StatusCodes.OK) - assert(entityAs[String] == "2,42") - } - } - "provide a default pagination" in { - Get("/") ~> route ~> check { - assert(status == StatusCodes.OK) - assert(entityAs[String] == "1,100") - } - } - "provide default values for a partial pagination" in { - Get("/?pageSize=2") ~> route ~> check { - assert(status == StatusCodes.OK) - assert(entityAs[String] == "1,2") - } - } - "reject an invalid pagination" in { - Get("/?pageNumber=-1") ~> route ~> check { - assert(rejection.isInstanceOf[ValidationRejection]) - } - } - } - - "optional paginated directive" should { - val route: Route = rest.optionalPagination { paginated => - complete(StatusCodes.OK -> paginated.map(p => s"${p.pageNumber},${p.pageSize}").getOrElse("no pagination")) - } - "accept a pagination" in { - Get("/?pageNumber=2&pageSize=42") ~> route ~> check { - assert(status == StatusCodes.OK) - assert(entityAs[String] == "2,42") - } - } - "without pagination" in { - Get("/") ~> route ~> check { - assert(status == StatusCodes.OK) - assert(entityAs[String] == "no pagination") - } - } - "reject an invalid pagination" in { - Get("/?pageNumber=1") ~> route ~> check { - assert(rejection.isInstanceOf[ValidationRejection]) - } - } - } - - "completeWithPagination directive" when { - import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ - import spray.json.DefaultJsonProtocol._ - - val data = Seq.fill(103)(Random.alphanumeric.take(10).mkString) - val route: Route = - parameter('empty.as[Boolean] ? false) { isEmpty => - completeWithPagination[String] { - case Some(pagination) if isEmpty => - Future.successful(ListResponse(Seq(), 0, Some(pagination))) - case Some(pagination) => - val filtered = data.slice(pagination.offset, pagination.offset + pagination.pageSize) - Future.successful(ListResponse(filtered, data.size, Some(pagination))) - case None if isEmpty => Future.successful(ListResponse(Seq(), 0, None)) - case None => Future.successful(ListResponse(data, data.size, None)) - } - } - - "pagination is defined" should { - "return a response with pagination headers" in { - Get("/?pageNumber=2&pageSize=10") ~> route ~> check { - responseAs[Seq[String]] shouldBe data.slice(10, 20) - header(ContextHeaders.ResourceCount).map(_.value) should contain("103") - header(ContextHeaders.PageCount).map(_.value) should contain("11") - } - } - - "disallow pageSize <= 0" in { - Get("/?pageNumber=2&pageSize=0") ~> route ~> check { - rejection shouldBe a[ValidationRejection] - } - - Get("/?pageNumber=2&pageSize=-1") ~> route ~> check { - rejection shouldBe a[ValidationRejection] - } - } - - "disallow pageNumber <= 0" in { - Get("/?pageNumber=0&pageSize=10") ~> route ~> check { - rejection shouldBe a[ValidationRejection] - } - - Get("/?pageNumber=-1&pageSize=10") ~> route ~> check { - rejection shouldBe a[ValidationRejection] - } - } - - "return PageCount == 0 if returning an empty list" in { - Get("/?empty=true&pageNumber=2&pageSize=10") ~> route ~> check { - responseAs[Seq[String]] shouldBe empty - header(ContextHeaders.ResourceCount).map(_.value) should contain("0") - header(ContextHeaders.PageCount).map(_.value) should contain("0") - } - } - } - - "pagination is not defined" should { - "return a response with pagination headers and PageCount == 1" in { - Get("/") ~> route ~> check { - responseAs[Seq[String]] shouldBe data - header(ContextHeaders.ResourceCount).map(_.value) should contain("103") - header(ContextHeaders.PageCount).map(_.value) should contain("1") - } - } - - "return PageCount == 0 if returning an empty list" in { - Get("/?empty=true") ~> route ~> check { - responseAs[Seq[String]] shouldBe empty - header(ContextHeaders.ResourceCount).map(_.value) should contain("0") - header(ContextHeaders.PageCount).map(_.value) should contain("0") - } - } - } - } -} -- cgit v1.2.3 From eb6f97b4cac548999cbf192ee83d9ba9a253b7c8 Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Wed, 12 Sep 2018 17:30:33 -0700 Subject: Move database-related functionality to separate project This committ includes a breaking change. The database-specific utility "Converters" trait threw an exception "DatabaseException" defined in the rest package, thus breaking the dependency graph. The solution was to move the DatabaseException class from rest to database and not inherit ServiceExceptio any more. Unfortunately, the rest classes also require the database exception in propagating errors so this funtionality has been removed. The rationale is: 1. Database exceptions are rare and result in 500 errors anyway making the status code opaque to what actual error caused it. 2. In core 2.0, an improved tracing framework will make diagnosing and following database errors easier, thereby attenuating the need to forward details on service exceptions in responses. --- build.sbt | 9 +- .../xyz/driver/core/database/Converters.scala | 25 +++ .../core/database/PatchedHsqldbProfile.scala | 16 ++ .../xyz/driver/core/database/Repository.scala | 74 +++++++++ .../core/database/SlickGetResultSupport.scala | 30 ++++ .../scala/xyz/driver/core/database/database.scala | 178 +++++++++++++++++++++ .../scala/xyz/driver/core/database/package.scala | 61 +++++++ .../xyz/driver/core/database/DatabaseTest.scala | 42 +++++ .../src/main/scala/xyz/driver/core/json.scala | 2 - .../scala/xyz/driver/core/rest/DriverRoute.scala | 3 - .../driver/core/rest/errors/serviceException.scala | 3 - .../xyz/driver/core/database/Converters.scala | 26 --- .../driver/core/database/MdcAsyncExecutor.scala | 53 ------ .../core/database/PatchedHsqldbProfile.scala | 16 -- .../xyz/driver/core/database/Repository.scala | 74 --------- .../core/database/SlickGetResultSupport.scala | 30 ---- .../scala/xyz/driver/core/database/database.scala | 178 --------------------- .../scala/xyz/driver/core/database/package.scala | 61 ------- .../xyz/driver/core/database/DatabaseTest.scala | 42 ----- 19 files changed, 433 insertions(+), 490 deletions(-) create mode 100644 core-database/src/main/scala/xyz/driver/core/database/Converters.scala create mode 100644 core-database/src/main/scala/xyz/driver/core/database/PatchedHsqldbProfile.scala create mode 100644 core-database/src/main/scala/xyz/driver/core/database/Repository.scala create mode 100644 core-database/src/main/scala/xyz/driver/core/database/SlickGetResultSupport.scala create mode 100644 core-database/src/main/scala/xyz/driver/core/database/database.scala create mode 100644 core-database/src/main/scala/xyz/driver/core/database/package.scala create mode 100644 core-database/src/test/scala/xyz/driver/core/database/DatabaseTest.scala delete mode 100644 src/main/scala/xyz/driver/core/database/Converters.scala delete mode 100644 src/main/scala/xyz/driver/core/database/MdcAsyncExecutor.scala delete mode 100644 src/main/scala/xyz/driver/core/database/PatchedHsqldbProfile.scala delete mode 100644 src/main/scala/xyz/driver/core/database/Repository.scala delete mode 100644 src/main/scala/xyz/driver/core/database/SlickGetResultSupport.scala delete mode 100644 src/main/scala/xyz/driver/core/database/database.scala delete mode 100644 src/main/scala/xyz/driver/core/database/package.scala delete mode 100644 src/test/scala/xyz/driver/core/database/DatabaseTest.scala diff --git a/build.sbt b/build.sbt index 4f32af7..c57a7ca 100644 --- a/build.sbt +++ b/build.sbt @@ -72,9 +72,14 @@ lazy val `core-messaging` = project .dependsOn(`core-reporting`) .settings(testdeps) +lazy val `core-database` = project + .enablePlugins(LibraryPlugin) + .dependsOn(`core-types`) + .settings(testdeps) + lazy val `core-init` = project .enablePlugins(LibraryPlugin) - .dependsOn(`core-reporting`, `core-storage`, `core-messaging`, `core-rest`) + .dependsOn(`core-reporting`, `core-storage`, `core-messaging`, `core-rest`, `core-database`) .settings(testdeps) lazy val core = project @@ -91,5 +96,5 @@ lazy val core = project s"https://github.com/drivergroup/driver-core/blob/master€{FILE_PATH}.scala" ) ) - .dependsOn(`core-types`, `core-rest`, `core-reporting`, `core-storage`, `core-messaging`, `core-init`) + .dependsOn(`core-types`, `core-rest`, `core-reporting`, `core-storage`, `core-messaging`, `core-database`, `core-init`) .settings(testdeps) diff --git a/core-database/src/main/scala/xyz/driver/core/database/Converters.scala b/core-database/src/main/scala/xyz/driver/core/database/Converters.scala new file mode 100644 index 0000000..b0054ad --- /dev/null +++ b/core-database/src/main/scala/xyz/driver/core/database/Converters.scala @@ -0,0 +1,25 @@ +package xyz.driver.core.database + +import scala.reflect.ClassTag + +/** + * Helper methods for converting between table rows and Scala objects + */ +trait Converters { + def fromStringOrThrow[ADT](entityStr: String, mapper: (String => Option[ADT]), entityName: String): ADT = + mapper(entityStr).getOrElse(throw DatabaseException(s"Invalid $entityName in database: $entityStr")) + + def expectValid[ADT](mapper: String => Option[ADT], query: String)(implicit ct: ClassTag[ADT]): ADT = + fromStringOrThrow[ADT](query, mapper, ct.toString()) + + def expectExistsAndValid[ADT](mapper: String => Option[ADT], query: Option[String], contextMsg: String = "")( + implicit ct: ClassTag[ADT]): ADT = { + expectValid[ADT](mapper, query.getOrElse(throw DatabaseException(contextMsg))) + } + + def expectValidOrEmpty[ADT](mapper: String => Option[ADT], query: Option[String], contextMsg: String = "")( + implicit ct: ClassTag[ADT]): Option[ADT] = { + query.map(expectValid[ADT](mapper, _)) + } +} +final case class DatabaseException(message: String = "Database access error") extends RuntimeException(message) diff --git a/core-database/src/main/scala/xyz/driver/core/database/PatchedHsqldbProfile.scala b/core-database/src/main/scala/xyz/driver/core/database/PatchedHsqldbProfile.scala new file mode 100644 index 0000000..e2efd32 --- /dev/null +++ b/core-database/src/main/scala/xyz/driver/core/database/PatchedHsqldbProfile.scala @@ -0,0 +1,16 @@ +package xyz.driver.core.database + +import slick.jdbc.{HsqldbProfile, JdbcType} +import slick.ast.FieldSymbol +import slick.relational.RelationalProfile + +trait PatchedHsqldbProfile extends HsqldbProfile { + override def defaultSqlTypeName(tmd: JdbcType[_], sym: Option[FieldSymbol]): String = tmd.sqlType match { + case java.sql.Types.VARCHAR => + val size = sym.flatMap(_.findColumnOption[RelationalProfile.ColumnOption.Length]) + size.fold("LONGVARCHAR")(l => if (l.varying) s"VARCHAR(${l.length})" else s"CHAR(${l.length})") + case _ => super.defaultSqlTypeName(tmd, sym) + } +} + +object PatchedHsqldbProfile extends PatchedHsqldbProfile diff --git a/core-database/src/main/scala/xyz/driver/core/database/Repository.scala b/core-database/src/main/scala/xyz/driver/core/database/Repository.scala new file mode 100644 index 0000000..5d7f787 --- /dev/null +++ b/core-database/src/main/scala/xyz/driver/core/database/Repository.scala @@ -0,0 +1,74 @@ +package xyz.driver.core.database + +import scalaz.std.scalaFuture._ +import scalaz.{ListT, Monad, OptionT} +import slick.lifted.{AbstractTable, CanBeQueryCondition, RunnableCompiled} +import slick.{lifted => sl} + +import scala.concurrent.{ExecutionContext, Future} +import scala.language.higherKinds + +trait Repository { + type T[D] + implicit def monadT: Monad[T] + + def execute[D](operations: T[D]): Future[D] + def noAction[V](v: V): T[V] + def customAction[R](action: => Future[R]): T[R] + + def customAction[R](action: => OptionT[Future, R]): OptionT[T, R] = + OptionT[T, R](customAction(action.run)) +} + +class FutureRepository(executionContext: ExecutionContext) extends Repository { + implicit val exec: ExecutionContext = executionContext + override type T[D] = Future[D] + implicit val monadT: Monad[Future] = implicitly[Monad[Future]] + + def execute[D](operations: T[D]): Future[D] = operations + def noAction[V](v: V): T[V] = Future.successful(v) + def customAction[R](action: => Future[R]): T[R] = action +} + +class SlickRepository(database: Database, executionContext: ExecutionContext) extends Repository { + import database.profile.api._ + implicit val exec: ExecutionContext = executionContext + + override type T[D] = slick.dbio.DBIO[D] + + implicit protected class QueryOps[+E, U](query: Query[E, U, Seq]) { + def resultT: ListT[T, U] = ListT[T, U](query.result.map(_.toList)) + + def maybeFilter[V, R: CanBeQueryCondition](data: Option[V])(f: V => E => R): sl.Query[E, U, Seq] = + data.map(v => query.withFilter(f(v))).getOrElse(query) + } + + implicit protected class CompiledQueryOps[U](compiledQuery: RunnableCompiled[_, Seq[U]]) { + def resultT: ListT[T, U] = ListT.listT[T](compiledQuery.result.map(_.toList)) + } + + private val dbioMonad = new Monad[T] { + override def point[A](a: => A): T[A] = DBIO.successful(a) + + override def bind[A, B](fa: T[A])(f: A => T[B]): T[B] = fa.flatMap(f) + } + + override implicit def monadT: Monad[T] = dbioMonad + + override def execute[D](readOperations: T[D]): Future[D] = { + database.database.run(readOperations.transactionally) + } + + override def noAction[V](v: V): T[V] = DBIO.successful(v) + + override def customAction[R](action: => Future[R]): T[R] = DBIO.from(action) + + def affectsRows(updatesCount: Int): Option[Unit] = { + if (updatesCount > 0) Some(()) else None + } + + def insertReturning[AT <: AbstractTable[_], V](table: TableQuery[AT])( + row: AT#TableElementType): slick.dbio.DBIO[AT#TableElementType] = { + table.returning(table) += row + } +} diff --git a/core-database/src/main/scala/xyz/driver/core/database/SlickGetResultSupport.scala b/core-database/src/main/scala/xyz/driver/core/database/SlickGetResultSupport.scala new file mode 100644 index 0000000..8293371 --- /dev/null +++ b/core-database/src/main/scala/xyz/driver/core/database/SlickGetResultSupport.scala @@ -0,0 +1,30 @@ +package xyz.driver.core.database + +import slick.jdbc.GetResult +import xyz.driver.core.date.Date +import xyz.driver.core.time.Time +import xyz.driver.core.{Id, Name} + +trait SlickGetResultSupport { + implicit def GetId[U]: GetResult[Id[U]] = + GetResult(r => Id[U](r.nextString())) + implicit def GetIdOption[U]: GetResult[Option[Id[U]]] = + GetResult(_.nextStringOption().map(Id.apply[U])) + + implicit def GetName[U]: GetResult[Name[U]] = + GetResult(r => Name[U](r.nextString())) + implicit def GetNameOption[U]: GetResult[Option[Name[U]]] = + GetResult(_.nextStringOption().map(Name.apply[U])) + + implicit val GetTime: GetResult[Time] = + GetResult(r => Time(r.nextTimestamp.getTime)) + implicit val GetTimeOption: GetResult[Option[Time]] = + GetResult(_.nextTimestampOption().map(t => Time(t.getTime))) + + implicit val GetDate: GetResult[Date] = + GetResult(r => sqlDateToDate(r.nextDate())) + implicit val GetDateOption: GetResult[Option[Date]] = + GetResult(_.nextDateOption().map(sqlDateToDate)) +} + +object SlickGetResultSupport extends SlickGetResultSupport diff --git a/core-database/src/main/scala/xyz/driver/core/database/database.scala b/core-database/src/main/scala/xyz/driver/core/database/database.scala new file mode 100644 index 0000000..bd20b54 --- /dev/null +++ b/core-database/src/main/scala/xyz/driver/core/database/database.scala @@ -0,0 +1,178 @@ +package xyz.driver.core + +import slick.basic.DatabaseConfig +import slick.jdbc.JdbcProfile +import xyz.driver.core.date.Date +import xyz.driver.core.time.Time + +import scala.concurrent.Future +import com.typesafe.config.Config + +package database { + + import java.sql.SQLDataException + import java.time.{Instant, LocalDate} + + import eu.timepit.refined.api.{Refined, Validate} + import eu.timepit.refined.refineV + + trait Database { + val profile: JdbcProfile + val database: JdbcProfile#Backend#Database + } + + object Database { + def fromConfig(config: Config, databaseName: String): Database = { + val dbConfig: DatabaseConfig[JdbcProfile] = DatabaseConfig.forConfig(databaseName, config) + + new Database { + val profile: JdbcProfile = dbConfig.profile + val database: JdbcProfile#Backend#Database = dbConfig.db + } + } + + def fromConfig(databaseName: String): Database = { + fromConfig(com.typesafe.config.ConfigFactory.load(), databaseName) + } + } + + trait ColumnTypes { + val profile: JdbcProfile + } + + trait NameColumnTypes extends ColumnTypes { + import profile.api._ + implicit def `xyz.driver.core.Name.columnType`[T]: BaseColumnType[Name[T]] + } + + object NameColumnTypes { + trait StringName extends NameColumnTypes { + import profile.api._ + + override implicit def `xyz.driver.core.Name.columnType`[T]: BaseColumnType[Name[T]] = + MappedColumnType.base[Name[T], String](_.value, Name[T]) + } + } + + trait DateColumnTypes extends ColumnTypes { + import profile.api._ + implicit def `xyz.driver.core.time.Date.columnType`: BaseColumnType[Date] + implicit def `java.time.LocalDate.columnType`: BaseColumnType[LocalDate] + } + + object DateColumnTypes { + trait SqlDate extends DateColumnTypes { + import profile.api._ + + override implicit def `xyz.driver.core.time.Date.columnType`: BaseColumnType[Date] = + MappedColumnType.base[Date, java.sql.Date](dateToSqlDate, sqlDateToDate) + + override implicit def `java.time.LocalDate.columnType`: BaseColumnType[LocalDate] = + MappedColumnType.base[LocalDate, java.sql.Date](java.sql.Date.valueOf, _.toLocalDate) + } + } + + trait RefinedColumnTypes[T, Predicate] extends ColumnTypes { + import profile.api._ + implicit def `eu.timepit.refined.api.Refined`( + implicit columnType: BaseColumnType[T], + validate: Validate[T, Predicate]): BaseColumnType[T Refined Predicate] + } + + object RefinedColumnTypes { + trait RefinedValue[T, Predicate] extends RefinedColumnTypes[T, Predicate] { + import profile.api._ + override implicit def `eu.timepit.refined.api.Refined`( + implicit columnType: BaseColumnType[T], + validate: Validate[T, Predicate]): BaseColumnType[T Refined Predicate] = + MappedColumnType.base[T Refined Predicate, T]( + _.value, { dbValue => + refineV[Predicate](dbValue) match { + case Left(refinementError) => + throw new SQLDataException( + s"Value in the database doesn't match the refinement constraints: $refinementError") + case Right(refinedValue) => + refinedValue + } + } + ) + } + } + + trait IdColumnTypes extends ColumnTypes { + import profile.api._ + implicit def `xyz.driver.core.Id.columnType`[T]: BaseColumnType[Id[T]] + } + + object IdColumnTypes { + trait UUID extends IdColumnTypes { + import profile.api._ + + override implicit def `xyz.driver.core.Id.columnType`[T] = + MappedColumnType + .base[Id[T], java.util.UUID](id => java.util.UUID.fromString(id.value), uuid => Id[T](uuid.toString)) + } + trait SerialId extends IdColumnTypes { + import profile.api._ + + override implicit def `xyz.driver.core.Id.columnType`[T] = + MappedColumnType.base[Id[T], Long](_.value.toLong, serialId => Id[T](serialId.toString)) + } + trait NaturalId extends IdColumnTypes { + import profile.api._ + + override implicit def `xyz.driver.core.Id.columnType`[T] = + MappedColumnType.base[Id[T], String](_.value, Id[T]) + } + } + + trait TimestampColumnTypes extends ColumnTypes { + import profile.api._ + implicit def `xyz.driver.core.time.Time.columnType`: BaseColumnType[Time] + implicit def `java.time.Instant.columnType`: BaseColumnType[Instant] + } + + object TimestampColumnTypes { + trait SqlTimestamp extends TimestampColumnTypes { + import profile.api._ + + override implicit def `xyz.driver.core.time.Time.columnType`: BaseColumnType[Time] = + MappedColumnType.base[Time, java.sql.Timestamp]( + time => new java.sql.Timestamp(time.millis), + timestamp => Time(timestamp.getTime)) + + override implicit def `java.time.Instant.columnType`: BaseColumnType[Instant] = + MappedColumnType.base[Instant, java.sql.Timestamp](java.sql.Timestamp.from, _.toInstant) + } + + trait PrimitiveTimestamp extends TimestampColumnTypes { + import profile.api._ + + override implicit def `xyz.driver.core.time.Time.columnType`: BaseColumnType[Time] = + MappedColumnType.base[Time, Long](_.millis, Time.apply) + + override implicit def `java.time.Instant.columnType`: BaseColumnType[Instant] = + MappedColumnType.base[Instant, Long](_.toEpochMilli, Instant.ofEpochMilli) + } + } + + trait KeyMappers extends ColumnTypes { + import profile.api._ + + def uuidKeyMapper[T] = + MappedColumnType + .base[Id[T], java.util.UUID](id => java.util.UUID.fromString(id.value), uuid => Id[T](uuid.toString)) + def serialKeyMapper[T] = MappedColumnType.base[Id[T], Long](_.value.toLong, serialId => Id[T](serialId.toString)) + def naturalKeyMapper[T] = MappedColumnType.base[Id[T], String](_.value, Id[T]) + } + + trait DatabaseObject extends ColumnTypes { + def createTables(): Future[Unit] + def disconnect(): Unit + } + + abstract class DatabaseObjectAdapter extends DatabaseObject { + def createTables(): Future[Unit] = Future.successful(()) + def disconnect(): Unit = {} + } +} diff --git a/core-database/src/main/scala/xyz/driver/core/database/package.scala b/core-database/src/main/scala/xyz/driver/core/database/package.scala new file mode 100644 index 0000000..aee14c6 --- /dev/null +++ b/core-database/src/main/scala/xyz/driver/core/database/package.scala @@ -0,0 +1,61 @@ +package xyz.driver.core + +import java.sql.{Date => SqlDate} +import java.util.Calendar + +import date.{Date, Month} +import slick.dbio._ +import slick.jdbc.JdbcProfile +import slick.relational.RelationalProfile + +package object database { + + type Schema = { + def create: DBIOAction[Unit, NoStream, Effect.Schema] + def drop: DBIOAction[Unit, NoStream, Effect.Schema] + } + + @deprecated( + "sbt-slick-codegen 0.11.0+ no longer needs to generate these methods. Please use the new `CodegenTables` trait when upgrading.", + "driver-core 1.8.12") + type GeneratedTables = { + // structure of Slick data model traits generated by sbt-slick-codegen + val profile: JdbcProfile + def schema: profile.SchemaDescription + + def createNamespaceSchema: StreamingDBIO[Vector[Unit], Unit] + def dropNamespaceSchema: StreamingDBIO[Vector[Unit], Unit] + } + + /** A structural type for schema traits generated by sbt-slick-codegen. + * This will compile with codegen versions before 0.11.0, but note + * that methods in [[GeneratedTables]] are no longer generated. + */ + type CodegenTables[Profile <: RelationalProfile] = { + val profile: Profile + def schema: profile.SchemaDescription + } + + private[database] def sqlDateToDate(sqlDate: SqlDate): Date = { + // NOTE: SQL date does not have a time component, so this date + // should only be interpreted in the running JVMs timezone. + val cal = Calendar.getInstance() + cal.setTime(sqlDate) + Date(cal.get(Calendar.YEAR), Month(cal.get(Calendar.MONTH)), cal.get(Calendar.DAY_OF_MONTH)) + } + + private[database] def dateToSqlDate(date: Date): SqlDate = { + val cal = Calendar.getInstance() + cal.set(date.year, date.month, date.day, 0, 0, 0) + new SqlDate(cal.getTime.getTime) + } + + @deprecated("Dal is deprecated. Please use Repository trait instead!", "1.8.26") + type Dal = Repository + + @deprecated("SlickDal is deprecated. Please use SlickRepository class instead!", "1.8.26") + type SlickDal = SlickRepository + + @deprecated("FutureDal is deprecated. Please use FutureRepository class instead!", "1.8.26") + type FutureDal = FutureRepository +} diff --git a/core-database/src/test/scala/xyz/driver/core/database/DatabaseTest.scala b/core-database/src/test/scala/xyz/driver/core/database/DatabaseTest.scala new file mode 100644 index 0000000..8d2a4ac --- /dev/null +++ b/core-database/src/test/scala/xyz/driver/core/database/DatabaseTest.scala @@ -0,0 +1,42 @@ +package xyz.driver.core.database + +import org.scalatest.{FlatSpec, Matchers} +import org.scalatest.prop.Checkers +import xyz.driver.core.rest.errors.DatabaseException + +class DatabaseTest extends FlatSpec with Matchers with Checkers { + import xyz.driver.core.generators._ + "Date SQL converter" should "correctly convert back and forth to SQL dates" in { + for (date <- 1 to 100 map (_ => nextDate())) { + sqlDateToDate(dateToSqlDate(date)) should be(date) + } + } + + "Converter helper methods" should "work correctly" in { + object TestConverter extends Converters + + val validLength = nextInt(10) + val valid = nextToken(validLength) + val validOp = Some(valid) + val invalid = nextToken(validLength + nextInt(10, 1)) + val invalidOp = Some(invalid) + def mapper(s: String): Option[String] = if (s.length == validLength) Some(s) else None + + TestConverter.fromStringOrThrow(valid, mapper, valid) should be(valid) + + TestConverter.expectValid(mapper, valid) should be(valid) + + TestConverter.expectExistsAndValid(mapper, validOp) should be(valid) + + TestConverter.expectValidOrEmpty(mapper, validOp) should be(Some(valid)) + TestConverter.expectValidOrEmpty(mapper, None) should be(None) + + an[DatabaseException] should be thrownBy TestConverter.fromStringOrThrow(invalid, mapper, invalid) + + an[DatabaseException] should be thrownBy TestConverter.expectValid(mapper, invalid) + + an[DatabaseException] should be thrownBy TestConverter.expectExistsAndValid(mapper, invalidOp) + + an[DatabaseException] should be thrownBy TestConverter.expectValidOrEmpty(mapper, invalidOp) + } +} diff --git a/core-rest/src/main/scala/xyz/driver/core/json.scala b/core-rest/src/main/scala/xyz/driver/core/json.scala index edc2347..40888aa 100644 --- a/core-rest/src/main/scala/xyz/driver/core/json.scala +++ b/core-rest/src/main/scala/xyz/driver/core/json.scala @@ -383,7 +383,6 @@ object json extends PathMatchers with Unmarshallers { case _: ResourceNotFoundException => "ResourceNotFoundException" case _: ExternalServiceException => "ExternalServiceException" case _: ExternalServiceTimeoutException => "ExternalServiceTimeoutException" - case _: DatabaseException => "DatabaseException" } { case "InvalidInputException" => jsonFormat(InvalidInputException, "message") case "InvalidActionException" => jsonFormat(InvalidActionException, "message") @@ -392,7 +391,6 @@ object json extends PathMatchers with Unmarshallers { case "ExternalServiceException" => jsonFormat(ExternalServiceException, "serviceName", "serviceMessage", "serviceException") case "ExternalServiceTimeoutException" => jsonFormat(ExternalServiceTimeoutException, "message") - case "DatabaseException" => jsonFormat(DatabaseException, "message") } } diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/DriverRoute.scala b/core-rest/src/main/scala/xyz/driver/core/rest/DriverRoute.scala index 911e306..b94f611 100644 --- a/core-rest/src/main/scala/xyz/driver/core/rest/DriverRoute.scala +++ b/core-rest/src/main/scala/xyz/driver/core/rest/DriverRoute.scala @@ -88,9 +88,6 @@ trait DriverRoute { case e: ExternalServiceTimeoutException => log.error("Service timeout error", e) StatusCodes.GatewayTimeout - case e: DatabaseException => - log.error("Database error", e) - StatusCodes.InternalServerError } { (ctx: RequestContext) => diff --git a/core-rest/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala b/core-rest/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala index f2962c9..b43a1d3 100644 --- a/core-rest/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala +++ b/core-rest/src/main/scala/xyz/driver/core/rest/errors/serviceException.scala @@ -22,6 +22,3 @@ final case class ExternalServiceException( final case class ExternalServiceTimeoutException(serviceName: String) extends ServiceException(s"$serviceName took too long to respond") - -final case class DatabaseException(override val message: String = "Database access error") - extends ServiceException(message) diff --git a/src/main/scala/xyz/driver/core/database/Converters.scala b/src/main/scala/xyz/driver/core/database/Converters.scala deleted file mode 100644 index ad79abf..0000000 --- a/src/main/scala/xyz/driver/core/database/Converters.scala +++ /dev/null @@ -1,26 +0,0 @@ -package xyz.driver.core.database - -import xyz.driver.core.rest.errors.DatabaseException - -import scala.reflect.ClassTag - -/** - * Helper methods for converting between table rows and Scala objects - */ -trait Converters { - def fromStringOrThrow[ADT](entityStr: String, mapper: (String => Option[ADT]), entityName: String): ADT = - mapper(entityStr).getOrElse(throw DatabaseException(s"Invalid $entityName in database: $entityStr")) - - def expectValid[ADT](mapper: String => Option[ADT], query: String)(implicit ct: ClassTag[ADT]): ADT = - fromStringOrThrow[ADT](query, mapper, ct.toString()) - - def expectExistsAndValid[ADT](mapper: String => Option[ADT], query: Option[String], contextMsg: String = "")( - implicit ct: ClassTag[ADT]): ADT = { - expectValid[ADT](mapper, query.getOrElse(throw DatabaseException(contextMsg))) - } - - def expectValidOrEmpty[ADT](mapper: String => Option[ADT], query: Option[String], contextMsg: String = "")( - implicit ct: ClassTag[ADT]): Option[ADT] = { - query.map(expectValid[ADT](mapper, _)) - } -} diff --git a/src/main/scala/xyz/driver/core/database/MdcAsyncExecutor.scala b/src/main/scala/xyz/driver/core/database/MdcAsyncExecutor.scala deleted file mode 100644 index 5939efb..0000000 --- a/src/main/scala/xyz/driver/core/database/MdcAsyncExecutor.scala +++ /dev/null @@ -1,53 +0,0 @@ -/** Code ported from "de.geekonaut" %% "slickmdc" % "1.0.0" - * License: @see https://github.com/AVGP/slickmdc/blob/master/LICENSE - * Blog post: @see http://50linesofco.de/post/2016-07-01-slick-and-slf4j-mdc-logging-in-scala.html - */ -package xyz.driver.core -package database - -import java.util.concurrent._ -import java.util.concurrent.atomic.AtomicInteger - -import scala.concurrent._ -import com.typesafe.scalalogging.StrictLogging -import slick.util.AsyncExecutor - -import logging.MdcExecutionContext - -/** Taken from the original Slick AsyncExecutor and simplified - * @see https://github.com/slick/slick/blob/3.1/slick/src/main/scala/slick/util/AsyncExecutor.scala - */ -object MdcAsyncExecutor extends StrictLogging { - - /** Create an AsyncExecutor with a fixed-size thread pool. - * - * @param name The name for the thread pool. - * @param numThreads The number of threads in the pool. - */ - def apply(name: String, numThreads: Int): AsyncExecutor = { - new AsyncExecutor { - val tf = new DaemonThreadFactory(name + "-") - - lazy val executionContext = { - new MdcExecutionContext(ExecutionContext.fromExecutor(Executors.newFixedThreadPool(numThreads, tf))) - } - - def close(): Unit = {} - } - } - - def default(name: String = "AsyncExecutor.default"): AsyncExecutor = apply(name, 20) - - private class DaemonThreadFactory(namePrefix: String) extends ThreadFactory { - private[this] val group = - Option(System.getSecurityManager).fold(Thread.currentThread.getThreadGroup)(_.getThreadGroup) - private[this] val threadNumber = new AtomicInteger(1) - - def newThread(r: Runnable): Thread = { - val t = new Thread(group, r, namePrefix + threadNumber.getAndIncrement, 0) - if (!t.isDaemon) t.setDaemon(true) - if (t.getPriority != Thread.NORM_PRIORITY) t.setPriority(Thread.NORM_PRIORITY) - t - } - } -} diff --git a/src/main/scala/xyz/driver/core/database/PatchedHsqldbProfile.scala b/src/main/scala/xyz/driver/core/database/PatchedHsqldbProfile.scala deleted file mode 100644 index e2efd32..0000000 --- a/src/main/scala/xyz/driver/core/database/PatchedHsqldbProfile.scala +++ /dev/null @@ -1,16 +0,0 @@ -package xyz.driver.core.database - -import slick.jdbc.{HsqldbProfile, JdbcType} -import slick.ast.FieldSymbol -import slick.relational.RelationalProfile - -trait PatchedHsqldbProfile extends HsqldbProfile { - override def defaultSqlTypeName(tmd: JdbcType[_], sym: Option[FieldSymbol]): String = tmd.sqlType match { - case java.sql.Types.VARCHAR => - val size = sym.flatMap(_.findColumnOption[RelationalProfile.ColumnOption.Length]) - size.fold("LONGVARCHAR")(l => if (l.varying) s"VARCHAR(${l.length})" else s"CHAR(${l.length})") - case _ => super.defaultSqlTypeName(tmd, sym) - } -} - -object PatchedHsqldbProfile extends PatchedHsqldbProfile diff --git a/src/main/scala/xyz/driver/core/database/Repository.scala b/src/main/scala/xyz/driver/core/database/Repository.scala deleted file mode 100644 index 5d7f787..0000000 --- a/src/main/scala/xyz/driver/core/database/Repository.scala +++ /dev/null @@ -1,74 +0,0 @@ -package xyz.driver.core.database - -import scalaz.std.scalaFuture._ -import scalaz.{ListT, Monad, OptionT} -import slick.lifted.{AbstractTable, CanBeQueryCondition, RunnableCompiled} -import slick.{lifted => sl} - -import scala.concurrent.{ExecutionContext, Future} -import scala.language.higherKinds - -trait Repository { - type T[D] - implicit def monadT: Monad[T] - - def execute[D](operations: T[D]): Future[D] - def noAction[V](v: V): T[V] - def customAction[R](action: => Future[R]): T[R] - - def customAction[R](action: => OptionT[Future, R]): OptionT[T, R] = - OptionT[T, R](customAction(action.run)) -} - -class FutureRepository(executionContext: ExecutionContext) extends Repository { - implicit val exec: ExecutionContext = executionContext - override type T[D] = Future[D] - implicit val monadT: Monad[Future] = implicitly[Monad[Future]] - - def execute[D](operations: T[D]): Future[D] = operations - def noAction[V](v: V): T[V] = Future.successful(v) - def customAction[R](action: => Future[R]): T[R] = action -} - -class SlickRepository(database: Database, executionContext: ExecutionContext) extends Repository { - import database.profile.api._ - implicit val exec: ExecutionContext = executionContext - - override type T[D] = slick.dbio.DBIO[D] - - implicit protected class QueryOps[+E, U](query: Query[E, U, Seq]) { - def resultT: ListT[T, U] = ListT[T, U](query.result.map(_.toList)) - - def maybeFilter[V, R: CanBeQueryCondition](data: Option[V])(f: V => E => R): sl.Query[E, U, Seq] = - data.map(v => query.withFilter(f(v))).getOrElse(query) - } - - implicit protected class CompiledQueryOps[U](compiledQuery: RunnableCompiled[_, Seq[U]]) { - def resultT: ListT[T, U] = ListT.listT[T](compiledQuery.result.map(_.toList)) - } - - private val dbioMonad = new Monad[T] { - override def point[A](a: => A): T[A] = DBIO.successful(a) - - override def bind[A, B](fa: T[A])(f: A => T[B]): T[B] = fa.flatMap(f) - } - - override implicit def monadT: Monad[T] = dbioMonad - - override def execute[D](readOperations: T[D]): Future[D] = { - database.database.run(readOperations.transactionally) - } - - override def noAction[V](v: V): T[V] = DBIO.successful(v) - - override def customAction[R](action: => Future[R]): T[R] = DBIO.from(action) - - def affectsRows(updatesCount: Int): Option[Unit] = { - if (updatesCount > 0) Some(()) else None - } - - def insertReturning[AT <: AbstractTable[_], V](table: TableQuery[AT])( - row: AT#TableElementType): slick.dbio.DBIO[AT#TableElementType] = { - table.returning(table) += row - } -} diff --git a/src/main/scala/xyz/driver/core/database/SlickGetResultSupport.scala b/src/main/scala/xyz/driver/core/database/SlickGetResultSupport.scala deleted file mode 100644 index 8293371..0000000 --- a/src/main/scala/xyz/driver/core/database/SlickGetResultSupport.scala +++ /dev/null @@ -1,30 +0,0 @@ -package xyz.driver.core.database - -import slick.jdbc.GetResult -import xyz.driver.core.date.Date -import xyz.driver.core.time.Time -import xyz.driver.core.{Id, Name} - -trait SlickGetResultSupport { - implicit def GetId[U]: GetResult[Id[U]] = - GetResult(r => Id[U](r.nextString())) - implicit def GetIdOption[U]: GetResult[Option[Id[U]]] = - GetResult(_.nextStringOption().map(Id.apply[U])) - - implicit def GetName[U]: GetResult[Name[U]] = - GetResult(r => Name[U](r.nextString())) - implicit def GetNameOption[U]: GetResult[Option[Name[U]]] = - GetResult(_.nextStringOption().map(Name.apply[U])) - - implicit val GetTime: GetResult[Time] = - GetResult(r => Time(r.nextTimestamp.getTime)) - implicit val GetTimeOption: GetResult[Option[Time]] = - GetResult(_.nextTimestampOption().map(t => Time(t.getTime))) - - implicit val GetDate: GetResult[Date] = - GetResult(r => sqlDateToDate(r.nextDate())) - implicit val GetDateOption: GetResult[Option[Date]] = - GetResult(_.nextDateOption().map(sqlDateToDate)) -} - -object SlickGetResultSupport extends SlickGetResultSupport diff --git a/src/main/scala/xyz/driver/core/database/database.scala b/src/main/scala/xyz/driver/core/database/database.scala deleted file mode 100644 index bd20b54..0000000 --- a/src/main/scala/xyz/driver/core/database/database.scala +++ /dev/null @@ -1,178 +0,0 @@ -package xyz.driver.core - -import slick.basic.DatabaseConfig -import slick.jdbc.JdbcProfile -import xyz.driver.core.date.Date -import xyz.driver.core.time.Time - -import scala.concurrent.Future -import com.typesafe.config.Config - -package database { - - import java.sql.SQLDataException - import java.time.{Instant, LocalDate} - - import eu.timepit.refined.api.{Refined, Validate} - import eu.timepit.refined.refineV - - trait Database { - val profile: JdbcProfile - val database: JdbcProfile#Backend#Database - } - - object Database { - def fromConfig(config: Config, databaseName: String): Database = { - val dbConfig: DatabaseConfig[JdbcProfile] = DatabaseConfig.forConfig(databaseName, config) - - new Database { - val profile: JdbcProfile = dbConfig.profile - val database: JdbcProfile#Backend#Database = dbConfig.db - } - } - - def fromConfig(databaseName: String): Database = { - fromConfig(com.typesafe.config.ConfigFactory.load(), databaseName) - } - } - - trait ColumnTypes { - val profile: JdbcProfile - } - - trait NameColumnTypes extends ColumnTypes { - import profile.api._ - implicit def `xyz.driver.core.Name.columnType`[T]: BaseColumnType[Name[T]] - } - - object NameColumnTypes { - trait StringName extends NameColumnTypes { - import profile.api._ - - override implicit def `xyz.driver.core.Name.columnType`[T]: BaseColumnType[Name[T]] = - MappedColumnType.base[Name[T], String](_.value, Name[T]) - } - } - - trait DateColumnTypes extends ColumnTypes { - import profile.api._ - implicit def `xyz.driver.core.time.Date.columnType`: BaseColumnType[Date] - implicit def `java.time.LocalDate.columnType`: BaseColumnType[LocalDate] - } - - object DateColumnTypes { - trait SqlDate extends DateColumnTypes { - import profile.api._ - - override implicit def `xyz.driver.core.time.Date.columnType`: BaseColumnType[Date] = - MappedColumnType.base[Date, java.sql.Date](dateToSqlDate, sqlDateToDate) - - override implicit def `java.time.LocalDate.columnType`: BaseColumnType[LocalDate] = - MappedColumnType.base[LocalDate, java.sql.Date](java.sql.Date.valueOf, _.toLocalDate) - } - } - - trait RefinedColumnTypes[T, Predicate] extends ColumnTypes { - import profile.api._ - implicit def `eu.timepit.refined.api.Refined`( - implicit columnType: BaseColumnType[T], - validate: Validate[T, Predicate]): BaseColumnType[T Refined Predicate] - } - - object RefinedColumnTypes { - trait RefinedValue[T, Predicate] extends RefinedColumnTypes[T, Predicate] { - import profile.api._ - override implicit def `eu.timepit.refined.api.Refined`( - implicit columnType: BaseColumnType[T], - validate: Validate[T, Predicate]): BaseColumnType[T Refined Predicate] = - MappedColumnType.base[T Refined Predicate, T]( - _.value, { dbValue => - refineV[Predicate](dbValue) match { - case Left(refinementError) => - throw new SQLDataException( - s"Value in the database doesn't match the refinement constraints: $refinementError") - case Right(refinedValue) => - refinedValue - } - } - ) - } - } - - trait IdColumnTypes extends ColumnTypes { - import profile.api._ - implicit def `xyz.driver.core.Id.columnType`[T]: BaseColumnType[Id[T]] - } - - object IdColumnTypes { - trait UUID extends IdColumnTypes { - import profile.api._ - - override implicit def `xyz.driver.core.Id.columnType`[T] = - MappedColumnType - .base[Id[T], java.util.UUID](id => java.util.UUID.fromString(id.value), uuid => Id[T](uuid.toString)) - } - trait SerialId extends IdColumnTypes { - import profile.api._ - - override implicit def `xyz.driver.core.Id.columnType`[T] = - MappedColumnType.base[Id[T], Long](_.value.toLong, serialId => Id[T](serialId.toString)) - } - trait NaturalId extends IdColumnTypes { - import profile.api._ - - override implicit def `xyz.driver.core.Id.columnType`[T] = - MappedColumnType.base[Id[T], String](_.value, Id[T]) - } - } - - trait TimestampColumnTypes extends ColumnTypes { - import profile.api._ - implicit def `xyz.driver.core.time.Time.columnType`: BaseColumnType[Time] - implicit def `java.time.Instant.columnType`: BaseColumnType[Instant] - } - - object TimestampColumnTypes { - trait SqlTimestamp extends TimestampColumnTypes { - import profile.api._ - - override implicit def `xyz.driver.core.time.Time.columnType`: BaseColumnType[Time] = - MappedColumnType.base[Time, java.sql.Timestamp]( - time => new java.sql.Timestamp(time.millis), - timestamp => Time(timestamp.getTime)) - - override implicit def `java.time.Instant.columnType`: BaseColumnType[Instant] = - MappedColumnType.base[Instant, java.sql.Timestamp](java.sql.Timestamp.from, _.toInstant) - } - - trait PrimitiveTimestamp extends TimestampColumnTypes { - import profile.api._ - - override implicit def `xyz.driver.core.time.Time.columnType`: BaseColumnType[Time] = - MappedColumnType.base[Time, Long](_.millis, Time.apply) - - override implicit def `java.time.Instant.columnType`: BaseColumnType[Instant] = - MappedColumnType.base[Instant, Long](_.toEpochMilli, Instant.ofEpochMilli) - } - } - - trait KeyMappers extends ColumnTypes { - import profile.api._ - - def uuidKeyMapper[T] = - MappedColumnType - .base[Id[T], java.util.UUID](id => java.util.UUID.fromString(id.value), uuid => Id[T](uuid.toString)) - def serialKeyMapper[T] = MappedColumnType.base[Id[T], Long](_.value.toLong, serialId => Id[T](serialId.toString)) - def naturalKeyMapper[T] = MappedColumnType.base[Id[T], String](_.value, Id[T]) - } - - trait DatabaseObject extends ColumnTypes { - def createTables(): Future[Unit] - def disconnect(): Unit - } - - abstract class DatabaseObjectAdapter extends DatabaseObject { - def createTables(): Future[Unit] = Future.successful(()) - def disconnect(): Unit = {} - } -} diff --git a/src/main/scala/xyz/driver/core/database/package.scala b/src/main/scala/xyz/driver/core/database/package.scala deleted file mode 100644 index aee14c6..0000000 --- a/src/main/scala/xyz/driver/core/database/package.scala +++ /dev/null @@ -1,61 +0,0 @@ -package xyz.driver.core - -import java.sql.{Date => SqlDate} -import java.util.Calendar - -import date.{Date, Month} -import slick.dbio._ -import slick.jdbc.JdbcProfile -import slick.relational.RelationalProfile - -package object database { - - type Schema = { - def create: DBIOAction[Unit, NoStream, Effect.Schema] - def drop: DBIOAction[Unit, NoStream, Effect.Schema] - } - - @deprecated( - "sbt-slick-codegen 0.11.0+ no longer needs to generate these methods. Please use the new `CodegenTables` trait when upgrading.", - "driver-core 1.8.12") - type GeneratedTables = { - // structure of Slick data model traits generated by sbt-slick-codegen - val profile: JdbcProfile - def schema: profile.SchemaDescription - - def createNamespaceSchema: StreamingDBIO[Vector[Unit], Unit] - def dropNamespaceSchema: StreamingDBIO[Vector[Unit], Unit] - } - - /** A structural type for schema traits generated by sbt-slick-codegen. - * This will compile with codegen versions before 0.11.0, but note - * that methods in [[GeneratedTables]] are no longer generated. - */ - type CodegenTables[Profile <: RelationalProfile] = { - val profile: Profile - def schema: profile.SchemaDescription - } - - private[database] def sqlDateToDate(sqlDate: SqlDate): Date = { - // NOTE: SQL date does not have a time component, so this date - // should only be interpreted in the running JVMs timezone. - val cal = Calendar.getInstance() - cal.setTime(sqlDate) - Date(cal.get(Calendar.YEAR), Month(cal.get(Calendar.MONTH)), cal.get(Calendar.DAY_OF_MONTH)) - } - - private[database] def dateToSqlDate(date: Date): SqlDate = { - val cal = Calendar.getInstance() - cal.set(date.year, date.month, date.day, 0, 0, 0) - new SqlDate(cal.getTime.getTime) - } - - @deprecated("Dal is deprecated. Please use Repository trait instead!", "1.8.26") - type Dal = Repository - - @deprecated("SlickDal is deprecated. Please use SlickRepository class instead!", "1.8.26") - type SlickDal = SlickRepository - - @deprecated("FutureDal is deprecated. Please use FutureRepository class instead!", "1.8.26") - type FutureDal = FutureRepository -} diff --git a/src/test/scala/xyz/driver/core/database/DatabaseTest.scala b/src/test/scala/xyz/driver/core/database/DatabaseTest.scala deleted file mode 100644 index 8d2a4ac..0000000 --- a/src/test/scala/xyz/driver/core/database/DatabaseTest.scala +++ /dev/null @@ -1,42 +0,0 @@ -package xyz.driver.core.database - -import org.scalatest.{FlatSpec, Matchers} -import org.scalatest.prop.Checkers -import xyz.driver.core.rest.errors.DatabaseException - -class DatabaseTest extends FlatSpec with Matchers with Checkers { - import xyz.driver.core.generators._ - "Date SQL converter" should "correctly convert back and forth to SQL dates" in { - for (date <- 1 to 100 map (_ => nextDate())) { - sqlDateToDate(dateToSqlDate(date)) should be(date) - } - } - - "Converter helper methods" should "work correctly" in { - object TestConverter extends Converters - - val validLength = nextInt(10) - val valid = nextToken(validLength) - val validOp = Some(valid) - val invalid = nextToken(validLength + nextInt(10, 1)) - val invalidOp = Some(invalid) - def mapper(s: String): Option[String] = if (s.length == validLength) Some(s) else None - - TestConverter.fromStringOrThrow(valid, mapper, valid) should be(valid) - - TestConverter.expectValid(mapper, valid) should be(valid) - - TestConverter.expectExistsAndValid(mapper, validOp) should be(valid) - - TestConverter.expectValidOrEmpty(mapper, validOp) should be(Some(valid)) - TestConverter.expectValidOrEmpty(mapper, None) should be(None) - - an[DatabaseException] should be thrownBy TestConverter.fromStringOrThrow(invalid, mapper, invalid) - - an[DatabaseException] should be thrownBy TestConverter.expectValid(mapper, invalid) - - an[DatabaseException] should be thrownBy TestConverter.expectExistsAndValid(mapper, invalidOp) - - an[DatabaseException] should be thrownBy TestConverter.expectValidOrEmpty(mapper, invalidOp) - } -} -- cgit v1.2.3 From b175e287bab6ed7d40ca4c9291cb24e20d93398d Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Wed, 12 Sep 2018 17:47:25 -0700 Subject: Move init package to separate project --- .../scala/xyz/driver/core/init/AkkaBootable.scala | 190 +++++++++++++++++++++ .../xyz/driver/core/init/BuildInfoReflection.scala | 37 ++++ .../scala/xyz/driver/core/init/CloudServices.scala | 89 ++++++++++ .../main/scala/xyz/driver/core/init/HttpApi.scala | 100 +++++++++++ .../main/scala/xyz/driver/core/init/Platform.scala | 31 ++++ .../scala/xyz/driver/core/init/ProtobufApi.scala | 7 + .../scala/xyz/driver/core/init/SimpleHttpApp.scala | 4 + .../scala/xyz/driver/core/init/AkkaBootable.scala | 190 --------------------- .../xyz/driver/core/init/BuildInfoReflection.scala | 37 ---- .../scala/xyz/driver/core/init/CloudServices.scala | 89 ---------- src/main/scala/xyz/driver/core/init/HttpApi.scala | 100 ----------- src/main/scala/xyz/driver/core/init/Platform.scala | 31 ---- .../scala/xyz/driver/core/init/ProtobufApi.scala | 7 - .../scala/xyz/driver/core/init/SimpleHttpApp.scala | 4 - 14 files changed, 458 insertions(+), 458 deletions(-) create mode 100644 core-init/src/main/scala/xyz/driver/core/init/AkkaBootable.scala create mode 100644 core-init/src/main/scala/xyz/driver/core/init/BuildInfoReflection.scala create mode 100644 core-init/src/main/scala/xyz/driver/core/init/CloudServices.scala create mode 100644 core-init/src/main/scala/xyz/driver/core/init/HttpApi.scala create mode 100644 core-init/src/main/scala/xyz/driver/core/init/Platform.scala create mode 100644 core-init/src/main/scala/xyz/driver/core/init/ProtobufApi.scala create mode 100644 core-init/src/main/scala/xyz/driver/core/init/SimpleHttpApp.scala delete mode 100644 src/main/scala/xyz/driver/core/init/AkkaBootable.scala delete mode 100644 src/main/scala/xyz/driver/core/init/BuildInfoReflection.scala delete mode 100644 src/main/scala/xyz/driver/core/init/CloudServices.scala delete mode 100644 src/main/scala/xyz/driver/core/init/HttpApi.scala delete mode 100644 src/main/scala/xyz/driver/core/init/Platform.scala delete mode 100644 src/main/scala/xyz/driver/core/init/ProtobufApi.scala delete mode 100644 src/main/scala/xyz/driver/core/init/SimpleHttpApp.scala diff --git a/core-init/src/main/scala/xyz/driver/core/init/AkkaBootable.scala b/core-init/src/main/scala/xyz/driver/core/init/AkkaBootable.scala new file mode 100644 index 0000000..df6611e --- /dev/null +++ b/core-init/src/main/scala/xyz/driver/core/init/AkkaBootable.scala @@ -0,0 +1,190 @@ +package xyz.driver.core +package init + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.server.{RequestContext, Route} +import akka.stream.scaladsl.Source +import akka.stream.{ActorMaterializer, Materializer} +import akka.util.ByteString +import com.softwaremill.sttp.SttpBackend +import com.softwaremill.sttp.akkahttp.AkkaHttpBackend +import com.typesafe.config.Config +import kamon.Kamon +import kamon.statsd.StatsDReporter +import kamon.system.SystemMetrics +import xyz.driver.core.reporting.{NoTraceReporter, Reporter, ScalaLoggingCompat, SpanContext} +import xyz.driver.core.rest.HttpRestServiceTransport + +import scala.concurrent.duration._ +import scala.concurrent.{Await, ExecutionContext, Future} + +/** Provides standard scaffolding for applications that use Akka HTTP. + * + * Among the features provided are: + * + * - execution contexts of various kinds + * - basic JVM metrics collection via Kamon + * - startup and shutdown hooks + * + * This trait provides a minimal, runnable application. It is designed to be extended by various mixins (see + * Known Subclasses) in this package. + * + * By implementing a "main" method, mixing this trait into a singleton object will result in a runnable + * application. + * I.e. + * {{{ + * object Main extends AkkaBootable // this is a runnable application + * }}} + * In case this trait isn't mixed into a top-level singleton object, the [[AkkaBootable#main main]] method should + * be called explicitly, in order to initialize and start this application. + * I.e. + * {{{ + * object Main { + * val bootable = new AkkaBootable {} + * def main(args: Array[String]): Unit = { + * bootable.main(args) + * } + * } + * }}} + * + * @groupname config Configuration + * @groupname contexts Contexts + * @groupname utilities Utilities + * @groupname hooks Overrideable Hooks + */ +trait AkkaBootable { + + /** The application's name. This value is extracted from the build configuration. + * @group config + */ + def name: String = BuildInfoReflection.name + + /** The application's version (or git sha). This value is extracted from the build configuration. + * @group config + */ + def version: Option[String] = BuildInfoReflection.version + + /** TCP port that this application will listen on. + * @group config + */ + def port: Int = 8080 + + // contexts + /** General-purpose actor system for this application. + * @group contexts + */ + implicit lazy val system: ActorSystem = ActorSystem(name) + + /** General-purpose stream materializer for this application. + * @group contexts + */ + implicit lazy val materializer: Materializer = ActorMaterializer() + + /** General-purpose execution context for this application. + * + * Note that no thread-blocking tasks should be submitted to this context. In cases that do require blocking, + * a custom execution context should be defined and used. See + * [[https://doc.akka.io/docs/akka-http/current/handling-blocking-operations-in-akka-http-routes.html this guide]] + * on how to configure custom execution contexts in Akka. + * + * @group contexts + */ + implicit lazy val executionContext: ExecutionContext = system.dispatcher + + /** Default HTTP client, backed by this application's actor system. + * @group contexts + */ + implicit lazy val httpClient: SttpBackend[Future, Source[ByteString, Any]] = AkkaHttpBackend.usingActorSystem(system) + + /** Client RPC transport abstraction. + * @group contexts + */ + implicit lazy val clientTransport: HttpRestServiceTransport = new HttpRestServiceTransport( + applicationName = Name(name), + applicationVersion = version.getOrElse(""), + actorSystem = system, + executionContext = executionContext, + reporter = reporter + ) + + // utilities + /** Default reporter instance. + * + * Note that this is currently defined to be a ScalaLoggerLike, so that it can be implicitly converted to a + * [[com.typesafe.scalalogging.Logger]] when necessary. This conversion is provided to ensure backwards + * compatibility with code that requires such a logger. Warning: using a logger instead of a reporter will + * not include tracing information in any messages! + * + * @group utilities + */ + def reporter: Reporter with ScalaLoggingCompat = + new Reporter with NoTraceReporter with ScalaLoggingCompat { + val logger = ScalaLoggingCompat.defaultScalaLogger(json = false) + } + + /** Top-level application configuration. + * + * TODO: should we expose some config wrapper rather than the typesafe config library? + * (Author's note: I'm a fan of TOML since it's so simple. There's already an implementation for Scala + * [[https://github.com/jvican/stoml]].) + * + * @group utilities + */ + def config: Config = system.settings.config + + /** Overridable startup hook. + * + * Invoked by [[main]] during application startup. + * + * @group hooks + */ + def startup(): Unit = () + + /** Overridable shutdown hook. + * + * Invoked on an arbitrary thread when a shutdown signal is caught. + * + * @group hooks + */ + def shutdown(): Unit = () + + /** Overridable HTTP route. + * + * Any services that present an HTTP interface should implement this method. + * + * @group hooks + * @see [[HttpApi]] + */ + def route: Route = (ctx: RequestContext) => ctx.complete(StatusCodes.NotFound) + + private def syslog(message: String)(implicit ctx: SpanContext) = reporter.info(s"application: " + message) + + /** This application's entry point. */ + def main(args: Array[String]): Unit = { + implicit val ctx = SpanContext.fresh() + syslog("initializing metrics collection") + Kamon.addReporter(new StatsDReporter()) + SystemMetrics.startCollecting() + + system.registerOnTermination { + syslog("running shutdown hooks") + shutdown() + syslog("bye!") + } + + syslog("running startup hooks") + startup() + + syslog("binding to network interface") + val binding = Await.result( + Http().bindAndHandle(route, "::", port), + 2.seconds + ) + syslog(s"listening to ${binding.localAddress}") + + syslog("startup complete") + } + +} diff --git a/core-init/src/main/scala/xyz/driver/core/init/BuildInfoReflection.scala b/core-init/src/main/scala/xyz/driver/core/init/BuildInfoReflection.scala new file mode 100644 index 0000000..0e53085 --- /dev/null +++ b/core-init/src/main/scala/xyz/driver/core/init/BuildInfoReflection.scala @@ -0,0 +1,37 @@ +package xyz.driver.core +package init + +import scala.reflect.runtime +import scala.util.Try +import scala.util.control.NonFatal + +/** Utility object to retrieve fields from static build configuration objects. */ +private[init] object BuildInfoReflection { + + final val BuildInfoName = "xyz.driver.BuildInfo" + + lazy val name: String = get[String]("name") + lazy val version: Option[String] = find[String]("version") + + /** Lookup a given field in the build configuration. This field is required to exist. */ + private def get[A](fieldName: String): A = + try { + val mirror = runtime.currentMirror + val module = mirror.staticModule(BuildInfoName) + val instance = mirror.reflectModule(module).instance + val accessor = module.info.decl(mirror.universe.TermName(fieldName)).asMethod + mirror.reflect(instance).reflectMethod(accessor).apply().asInstanceOf[A] + } catch { + case NonFatal(err) => + throw new RuntimeException( + s"Cannot find field name '$fieldName' in $BuildInfoName. Please define (or generate) a singleton " + + s"object with that field. Alternatively, in order to avoid runtime reflection, you may override the " + + s"caller with a static value.", + err + ) + } + + /** Try finding a given field in the build configuration. If the field does not exist, None is returned. */ + private def find[A](fieldName: String): Option[A] = Try { get[A](fieldName) }.toOption + +} diff --git a/core-init/src/main/scala/xyz/driver/core/init/CloudServices.scala b/core-init/src/main/scala/xyz/driver/core/init/CloudServices.scala new file mode 100644 index 0000000..857dd4c --- /dev/null +++ b/core-init/src/main/scala/xyz/driver/core/init/CloudServices.scala @@ -0,0 +1,89 @@ +package xyz.driver.core +package init + +import java.nio.file.Paths + +import xyz.driver.core.messaging.{CreateOnDemand, GoogleBus, QueueBus, StreamBus} +import xyz.driver.core.reporting._ +import xyz.driver.core.rest.DnsDiscovery +import xyz.driver.core.storage.{BlobStorage, FileSystemBlobStorage, GcsBlobStorage} + +import scala.collection.JavaConverters._ + +/** Mixin trait that provides essential cloud utilities. */ +trait CloudServices extends AkkaBootable { self => + + /** The platform that this application is running on. + * @group config + */ + def platform: Platform = Platform.current + + /** Service discovery for the current platform. + * @group utilities + */ + lazy val discovery: DnsDiscovery = { + def getOverrides(): Map[String, String] = { + val block = config.getObject("services.dev-overrides").unwrapped().asScala + for ((key, value) <- block) yield { + require(value.isInstanceOf[String], s"Service URL override for '$key' must be a string. Found '$value'.") + key -> value.toString + } + }.toMap + val overrides = platform match { + case Platform.Dev => getOverrides() + // TODO: currently, deployed services must be configured via Kubernetes DNS resolver. Maybe we may want to + // provide a way to override deployed services as well. + case _ => Map.empty[String, String] + } + new DnsDiscovery(clientTransport, overrides) + } + + /* TODO: this reporter uses the platform to determine if JSON logging should be enabled. + * Since the default logger uses slf4j, its settings must be specified before a logger + * is first accessed. This in turn leads to somewhat convoluted code, + * since we can't log when the platform being is determined. + * A potential fix would be to make the log format independent of the platform, and always log + * as JSON for example. + */ + override lazy val reporter: Reporter with ScalaLoggingCompat = { + Console.println("determining platform") // scalastyle:ignore + val r = platform match { + case p @ Platform.GoogleCloud(_, _) => + new GoogleReporter(p.credentials, p.namespace) with ScalaLoggingCompat with GoogleMdcLogger { + val logger = ScalaLoggingCompat.defaultScalaLogger(true) + } + case Platform.Dev => + new NoTraceReporter with ScalaLoggingCompat { + val logger = ScalaLoggingCompat.defaultScalaLogger(false) + } + } + r.info(s"application started on platform '${platform}'")(SpanContext.fresh()) + r + } + + /** Object storage. + * + * When running on a cloud platform, prepends `$project-` to bucket names, where `$project` + * is the project ID (for example 'driverinc-production` or `driverinc-sandbox`). + * + * @group utilities + */ + def storage(bucketName: String): BlobStorage = + platform match { + case p @ Platform.GoogleCloud(keyfile, _) => + GcsBlobStorage.fromKeyfile(keyfile, s"${p.project}-$bucketName") + case Platform.Dev => + new FileSystemBlobStorage(Paths.get(s".data-$bucketName")) + } + + /** Message bus. + * @group utilities + */ + def messageBus: StreamBus = platform match { + case p @ Platform.GoogleCloud(_, namespace) => + new GoogleBus(p.credentials, namespace) with StreamBus with CreateOnDemand + case Platform.Dev => + new QueueBus()(self.system) with StreamBus + } + +} diff --git a/core-init/src/main/scala/xyz/driver/core/init/HttpApi.scala b/core-init/src/main/scala/xyz/driver/core/init/HttpApi.scala new file mode 100644 index 0000000..81428bf --- /dev/null +++ b/core-init/src/main/scala/xyz/driver/core/init/HttpApi.scala @@ -0,0 +1,100 @@ +package xyz.driver.core +package init + +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport +import akka.http.scaladsl.server.{RequestContext, Route, RouteConcatenation} +import spray.json.DefaultJsonProtocol._ +import spray.json._ +import xyz.driver.core.rest.Swagger +import xyz.driver.core.rest.directives.Directives +import akka.http.scaladsl.model.headers._ +import xyz.driver.core.reporting.Reporter.CausalRelation +import xyz.driver.core.rest.headers.Traceparent + +import scala.collection.JavaConverters._ + +/** Mixin trait that provides some well-known HTTP endpoints, diagnostic header injection and forwarding, + * and exposes an application-specific route that must be implemented by services. + * @see ProtobufApi + */ +trait HttpApi extends CloudServices with Directives with SprayJsonSupport { self => + + /** Route that handles the application's business logic. + * @group hooks + */ + def applicationRoute: Route + + /** Classes with Swagger annotations. + * @group hooks + */ + def swaggerRouteClasses: Set[Class[_]] + + private val healthRoute = path("health") { + complete(Map("status" -> "good").toJson) + } + + private val versionRoute = path("version") { + complete(Map("name" -> self.name.toJson, "version" -> self.version.toJson).toJson) + } + + private lazy val swaggerRoute = { + val generator = new Swagger( + "", + "https" :: "http" :: Nil, + self.version.getOrElse(""), + swaggerRouteClasses, + config, + reporter + ) + generator.routes ~ generator.swaggerUINew + } + + private def cors(inner: Route): Route = + cors( + config.getStringList("application.cors.allowedOrigins").asScala.toSet, + xyz.driver.core.rest.AllowedHeaders + )(inner) + + private def traced(inner: Route): Route = (ctx: RequestContext) => { + val tags = Map( + "service.version" -> version.getOrElse(""), + // open tracing semantic tags + "span.kind" -> "server", + "service" -> name, + "http.url" -> ctx.request.uri.toString, + "http.method" -> ctx.request.method.value, + "peer.hostname" -> ctx.request.uri.authority.host.toString, + // google's tracing console provides extra search features if we define these tags + "/http/path" -> ctx.request.uri.path.toString, + "/http/method" -> ctx.request.method.value.toString, + "/http/url" -> ctx.request.uri.toString, + "/http/user_agent" -> ctx.request.header[`User-Agent`].map(_.value).getOrElse("") + ) + val parent = ctx.request.header[Traceparent].map { header => + header.spanContext -> CausalRelation.Child + } + reporter + .traceWithOptionalParentAsync(s"http_handle_rpc", tags, parent) { spanContext => + val header = Traceparent(spanContext) + val withHeader = ctx.withRequest( + ctx.request + .removeHeader(header.name) + .addHeader(header) + ) + inner(withHeader) + } + } + + /** Extended route. */ + override lazy val route: Route = traced( + cors( + RouteConcatenation.concat( + healthRoute, + versionRoute, + swaggerRoute, + applicationRoute + ) + ) + ) + +} diff --git a/core-init/src/main/scala/xyz/driver/core/init/Platform.scala b/core-init/src/main/scala/xyz/driver/core/init/Platform.scala new file mode 100644 index 0000000..2daa2c8 --- /dev/null +++ b/core-init/src/main/scala/xyz/driver/core/init/Platform.scala @@ -0,0 +1,31 @@ +package xyz.driver.core +package init + +import java.nio.file.{Files, Path, Paths} + +import com.google.auth.oauth2.ServiceAccountCredentials + +sealed trait Platform +object Platform { + case class GoogleCloud(keyfile: Path, namespace: String) extends Platform { + def credentials: ServiceAccountCredentials = ServiceAccountCredentials.fromStream( + Files.newInputStream(keyfile) + ) + def project: String = credentials.getProjectId + } + // case object AliCloud extends Platform + case object Dev extends Platform + + lazy val fromEnv: Platform = { + def isGoogle = sys.env.get("GOOGLE_APPLICATION_CREDENTIALS").map { value => + val keyfile = Paths.get(value) + require(Files.isReadable(keyfile), s"Google credentials file $value is not readable.") + val namespace = sys.env.getOrElse("SERVICE_NAMESPACE", sys.error("Namespace not set")) + GoogleCloud(keyfile, namespace) + } + isGoogle.getOrElse(Dev) + } + + def current: Platform = fromEnv + +} diff --git a/core-init/src/main/scala/xyz/driver/core/init/ProtobufApi.scala b/core-init/src/main/scala/xyz/driver/core/init/ProtobufApi.scala new file mode 100644 index 0000000..284ac67 --- /dev/null +++ b/core-init/src/main/scala/xyz/driver/core/init/ProtobufApi.scala @@ -0,0 +1,7 @@ +package xyz.driver.core +package init + +/** Mixin trait for services that implement an API based on Protocol Buffers and gRPC. + * TODO: implement + */ +trait ProtobufApi extends AkkaBootable diff --git a/core-init/src/main/scala/xyz/driver/core/init/SimpleHttpApp.scala b/core-init/src/main/scala/xyz/driver/core/init/SimpleHttpApp.scala new file mode 100644 index 0000000..61ca363 --- /dev/null +++ b/core-init/src/main/scala/xyz/driver/core/init/SimpleHttpApp.scala @@ -0,0 +1,4 @@ +package xyz.driver.core +package init + +trait SimpleHttpApp extends AkkaBootable with HttpApi with CloudServices diff --git a/src/main/scala/xyz/driver/core/init/AkkaBootable.scala b/src/main/scala/xyz/driver/core/init/AkkaBootable.scala deleted file mode 100644 index df6611e..0000000 --- a/src/main/scala/xyz/driver/core/init/AkkaBootable.scala +++ /dev/null @@ -1,190 +0,0 @@ -package xyz.driver.core -package init - -import akka.actor.ActorSystem -import akka.http.scaladsl.Http -import akka.http.scaladsl.model.StatusCodes -import akka.http.scaladsl.server.{RequestContext, Route} -import akka.stream.scaladsl.Source -import akka.stream.{ActorMaterializer, Materializer} -import akka.util.ByteString -import com.softwaremill.sttp.SttpBackend -import com.softwaremill.sttp.akkahttp.AkkaHttpBackend -import com.typesafe.config.Config -import kamon.Kamon -import kamon.statsd.StatsDReporter -import kamon.system.SystemMetrics -import xyz.driver.core.reporting.{NoTraceReporter, Reporter, ScalaLoggingCompat, SpanContext} -import xyz.driver.core.rest.HttpRestServiceTransport - -import scala.concurrent.duration._ -import scala.concurrent.{Await, ExecutionContext, Future} - -/** Provides standard scaffolding for applications that use Akka HTTP. - * - * Among the features provided are: - * - * - execution contexts of various kinds - * - basic JVM metrics collection via Kamon - * - startup and shutdown hooks - * - * This trait provides a minimal, runnable application. It is designed to be extended by various mixins (see - * Known Subclasses) in this package. - * - * By implementing a "main" method, mixing this trait into a singleton object will result in a runnable - * application. - * I.e. - * {{{ - * object Main extends AkkaBootable // this is a runnable application - * }}} - * In case this trait isn't mixed into a top-level singleton object, the [[AkkaBootable#main main]] method should - * be called explicitly, in order to initialize and start this application. - * I.e. - * {{{ - * object Main { - * val bootable = new AkkaBootable {} - * def main(args: Array[String]): Unit = { - * bootable.main(args) - * } - * } - * }}} - * - * @groupname config Configuration - * @groupname contexts Contexts - * @groupname utilities Utilities - * @groupname hooks Overrideable Hooks - */ -trait AkkaBootable { - - /** The application's name. This value is extracted from the build configuration. - * @group config - */ - def name: String = BuildInfoReflection.name - - /** The application's version (or git sha). This value is extracted from the build configuration. - * @group config - */ - def version: Option[String] = BuildInfoReflection.version - - /** TCP port that this application will listen on. - * @group config - */ - def port: Int = 8080 - - // contexts - /** General-purpose actor system for this application. - * @group contexts - */ - implicit lazy val system: ActorSystem = ActorSystem(name) - - /** General-purpose stream materializer for this application. - * @group contexts - */ - implicit lazy val materializer: Materializer = ActorMaterializer() - - /** General-purpose execution context for this application. - * - * Note that no thread-blocking tasks should be submitted to this context. In cases that do require blocking, - * a custom execution context should be defined and used. See - * [[https://doc.akka.io/docs/akka-http/current/handling-blocking-operations-in-akka-http-routes.html this guide]] - * on how to configure custom execution contexts in Akka. - * - * @group contexts - */ - implicit lazy val executionContext: ExecutionContext = system.dispatcher - - /** Default HTTP client, backed by this application's actor system. - * @group contexts - */ - implicit lazy val httpClient: SttpBackend[Future, Source[ByteString, Any]] = AkkaHttpBackend.usingActorSystem(system) - - /** Client RPC transport abstraction. - * @group contexts - */ - implicit lazy val clientTransport: HttpRestServiceTransport = new HttpRestServiceTransport( - applicationName = Name(name), - applicationVersion = version.getOrElse(""), - actorSystem = system, - executionContext = executionContext, - reporter = reporter - ) - - // utilities - /** Default reporter instance. - * - * Note that this is currently defined to be a ScalaLoggerLike, so that it can be implicitly converted to a - * [[com.typesafe.scalalogging.Logger]] when necessary. This conversion is provided to ensure backwards - * compatibility with code that requires such a logger. Warning: using a logger instead of a reporter will - * not include tracing information in any messages! - * - * @group utilities - */ - def reporter: Reporter with ScalaLoggingCompat = - new Reporter with NoTraceReporter with ScalaLoggingCompat { - val logger = ScalaLoggingCompat.defaultScalaLogger(json = false) - } - - /** Top-level application configuration. - * - * TODO: should we expose some config wrapper rather than the typesafe config library? - * (Author's note: I'm a fan of TOML since it's so simple. There's already an implementation for Scala - * [[https://github.com/jvican/stoml]].) - * - * @group utilities - */ - def config: Config = system.settings.config - - /** Overridable startup hook. - * - * Invoked by [[main]] during application startup. - * - * @group hooks - */ - def startup(): Unit = () - - /** Overridable shutdown hook. - * - * Invoked on an arbitrary thread when a shutdown signal is caught. - * - * @group hooks - */ - def shutdown(): Unit = () - - /** Overridable HTTP route. - * - * Any services that present an HTTP interface should implement this method. - * - * @group hooks - * @see [[HttpApi]] - */ - def route: Route = (ctx: RequestContext) => ctx.complete(StatusCodes.NotFound) - - private def syslog(message: String)(implicit ctx: SpanContext) = reporter.info(s"application: " + message) - - /** This application's entry point. */ - def main(args: Array[String]): Unit = { - implicit val ctx = SpanContext.fresh() - syslog("initializing metrics collection") - Kamon.addReporter(new StatsDReporter()) - SystemMetrics.startCollecting() - - system.registerOnTermination { - syslog("running shutdown hooks") - shutdown() - syslog("bye!") - } - - syslog("running startup hooks") - startup() - - syslog("binding to network interface") - val binding = Await.result( - Http().bindAndHandle(route, "::", port), - 2.seconds - ) - syslog(s"listening to ${binding.localAddress}") - - syslog("startup complete") - } - -} diff --git a/src/main/scala/xyz/driver/core/init/BuildInfoReflection.scala b/src/main/scala/xyz/driver/core/init/BuildInfoReflection.scala deleted file mode 100644 index 0e53085..0000000 --- a/src/main/scala/xyz/driver/core/init/BuildInfoReflection.scala +++ /dev/null @@ -1,37 +0,0 @@ -package xyz.driver.core -package init - -import scala.reflect.runtime -import scala.util.Try -import scala.util.control.NonFatal - -/** Utility object to retrieve fields from static build configuration objects. */ -private[init] object BuildInfoReflection { - - final val BuildInfoName = "xyz.driver.BuildInfo" - - lazy val name: String = get[String]("name") - lazy val version: Option[String] = find[String]("version") - - /** Lookup a given field in the build configuration. This field is required to exist. */ - private def get[A](fieldName: String): A = - try { - val mirror = runtime.currentMirror - val module = mirror.staticModule(BuildInfoName) - val instance = mirror.reflectModule(module).instance - val accessor = module.info.decl(mirror.universe.TermName(fieldName)).asMethod - mirror.reflect(instance).reflectMethod(accessor).apply().asInstanceOf[A] - } catch { - case NonFatal(err) => - throw new RuntimeException( - s"Cannot find field name '$fieldName' in $BuildInfoName. Please define (or generate) a singleton " + - s"object with that field. Alternatively, in order to avoid runtime reflection, you may override the " + - s"caller with a static value.", - err - ) - } - - /** Try finding a given field in the build configuration. If the field does not exist, None is returned. */ - private def find[A](fieldName: String): Option[A] = Try { get[A](fieldName) }.toOption - -} diff --git a/src/main/scala/xyz/driver/core/init/CloudServices.scala b/src/main/scala/xyz/driver/core/init/CloudServices.scala deleted file mode 100644 index 857dd4c..0000000 --- a/src/main/scala/xyz/driver/core/init/CloudServices.scala +++ /dev/null @@ -1,89 +0,0 @@ -package xyz.driver.core -package init - -import java.nio.file.Paths - -import xyz.driver.core.messaging.{CreateOnDemand, GoogleBus, QueueBus, StreamBus} -import xyz.driver.core.reporting._ -import xyz.driver.core.rest.DnsDiscovery -import xyz.driver.core.storage.{BlobStorage, FileSystemBlobStorage, GcsBlobStorage} - -import scala.collection.JavaConverters._ - -/** Mixin trait that provides essential cloud utilities. */ -trait CloudServices extends AkkaBootable { self => - - /** The platform that this application is running on. - * @group config - */ - def platform: Platform = Platform.current - - /** Service discovery for the current platform. - * @group utilities - */ - lazy val discovery: DnsDiscovery = { - def getOverrides(): Map[String, String] = { - val block = config.getObject("services.dev-overrides").unwrapped().asScala - for ((key, value) <- block) yield { - require(value.isInstanceOf[String], s"Service URL override for '$key' must be a string. Found '$value'.") - key -> value.toString - } - }.toMap - val overrides = platform match { - case Platform.Dev => getOverrides() - // TODO: currently, deployed services must be configured via Kubernetes DNS resolver. Maybe we may want to - // provide a way to override deployed services as well. - case _ => Map.empty[String, String] - } - new DnsDiscovery(clientTransport, overrides) - } - - /* TODO: this reporter uses the platform to determine if JSON logging should be enabled. - * Since the default logger uses slf4j, its settings must be specified before a logger - * is first accessed. This in turn leads to somewhat convoluted code, - * since we can't log when the platform being is determined. - * A potential fix would be to make the log format independent of the platform, and always log - * as JSON for example. - */ - override lazy val reporter: Reporter with ScalaLoggingCompat = { - Console.println("determining platform") // scalastyle:ignore - val r = platform match { - case p @ Platform.GoogleCloud(_, _) => - new GoogleReporter(p.credentials, p.namespace) with ScalaLoggingCompat with GoogleMdcLogger { - val logger = ScalaLoggingCompat.defaultScalaLogger(true) - } - case Platform.Dev => - new NoTraceReporter with ScalaLoggingCompat { - val logger = ScalaLoggingCompat.defaultScalaLogger(false) - } - } - r.info(s"application started on platform '${platform}'")(SpanContext.fresh()) - r - } - - /** Object storage. - * - * When running on a cloud platform, prepends `$project-` to bucket names, where `$project` - * is the project ID (for example 'driverinc-production` or `driverinc-sandbox`). - * - * @group utilities - */ - def storage(bucketName: String): BlobStorage = - platform match { - case p @ Platform.GoogleCloud(keyfile, _) => - GcsBlobStorage.fromKeyfile(keyfile, s"${p.project}-$bucketName") - case Platform.Dev => - new FileSystemBlobStorage(Paths.get(s".data-$bucketName")) - } - - /** Message bus. - * @group utilities - */ - def messageBus: StreamBus = platform match { - case p @ Platform.GoogleCloud(_, namespace) => - new GoogleBus(p.credentials, namespace) with StreamBus with CreateOnDemand - case Platform.Dev => - new QueueBus()(self.system) with StreamBus - } - -} diff --git a/src/main/scala/xyz/driver/core/init/HttpApi.scala b/src/main/scala/xyz/driver/core/init/HttpApi.scala deleted file mode 100644 index 81428bf..0000000 --- a/src/main/scala/xyz/driver/core/init/HttpApi.scala +++ /dev/null @@ -1,100 +0,0 @@ -package xyz.driver.core -package init - -import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport -import akka.http.scaladsl.server.{RequestContext, Route, RouteConcatenation} -import spray.json.DefaultJsonProtocol._ -import spray.json._ -import xyz.driver.core.rest.Swagger -import xyz.driver.core.rest.directives.Directives -import akka.http.scaladsl.model.headers._ -import xyz.driver.core.reporting.Reporter.CausalRelation -import xyz.driver.core.rest.headers.Traceparent - -import scala.collection.JavaConverters._ - -/** Mixin trait that provides some well-known HTTP endpoints, diagnostic header injection and forwarding, - * and exposes an application-specific route that must be implemented by services. - * @see ProtobufApi - */ -trait HttpApi extends CloudServices with Directives with SprayJsonSupport { self => - - /** Route that handles the application's business logic. - * @group hooks - */ - def applicationRoute: Route - - /** Classes with Swagger annotations. - * @group hooks - */ - def swaggerRouteClasses: Set[Class[_]] - - private val healthRoute = path("health") { - complete(Map("status" -> "good").toJson) - } - - private val versionRoute = path("version") { - complete(Map("name" -> self.name.toJson, "version" -> self.version.toJson).toJson) - } - - private lazy val swaggerRoute = { - val generator = new Swagger( - "", - "https" :: "http" :: Nil, - self.version.getOrElse(""), - swaggerRouteClasses, - config, - reporter - ) - generator.routes ~ generator.swaggerUINew - } - - private def cors(inner: Route): Route = - cors( - config.getStringList("application.cors.allowedOrigins").asScala.toSet, - xyz.driver.core.rest.AllowedHeaders - )(inner) - - private def traced(inner: Route): Route = (ctx: RequestContext) => { - val tags = Map( - "service.version" -> version.getOrElse(""), - // open tracing semantic tags - "span.kind" -> "server", - "service" -> name, - "http.url" -> ctx.request.uri.toString, - "http.method" -> ctx.request.method.value, - "peer.hostname" -> ctx.request.uri.authority.host.toString, - // google's tracing console provides extra search features if we define these tags - "/http/path" -> ctx.request.uri.path.toString, - "/http/method" -> ctx.request.method.value.toString, - "/http/url" -> ctx.request.uri.toString, - "/http/user_agent" -> ctx.request.header[`User-Agent`].map(_.value).getOrElse("") - ) - val parent = ctx.request.header[Traceparent].map { header => - header.spanContext -> CausalRelation.Child - } - reporter - .traceWithOptionalParentAsync(s"http_handle_rpc", tags, parent) { spanContext => - val header = Traceparent(spanContext) - val withHeader = ctx.withRequest( - ctx.request - .removeHeader(header.name) - .addHeader(header) - ) - inner(withHeader) - } - } - - /** Extended route. */ - override lazy val route: Route = traced( - cors( - RouteConcatenation.concat( - healthRoute, - versionRoute, - swaggerRoute, - applicationRoute - ) - ) - ) - -} diff --git a/src/main/scala/xyz/driver/core/init/Platform.scala b/src/main/scala/xyz/driver/core/init/Platform.scala deleted file mode 100644 index 2daa2c8..0000000 --- a/src/main/scala/xyz/driver/core/init/Platform.scala +++ /dev/null @@ -1,31 +0,0 @@ -package xyz.driver.core -package init - -import java.nio.file.{Files, Path, Paths} - -import com.google.auth.oauth2.ServiceAccountCredentials - -sealed trait Platform -object Platform { - case class GoogleCloud(keyfile: Path, namespace: String) extends Platform { - def credentials: ServiceAccountCredentials = ServiceAccountCredentials.fromStream( - Files.newInputStream(keyfile) - ) - def project: String = credentials.getProjectId - } - // case object AliCloud extends Platform - case object Dev extends Platform - - lazy val fromEnv: Platform = { - def isGoogle = sys.env.get("GOOGLE_APPLICATION_CREDENTIALS").map { value => - val keyfile = Paths.get(value) - require(Files.isReadable(keyfile), s"Google credentials file $value is not readable.") - val namespace = sys.env.getOrElse("SERVICE_NAMESPACE", sys.error("Namespace not set")) - GoogleCloud(keyfile, namespace) - } - isGoogle.getOrElse(Dev) - } - - def current: Platform = fromEnv - -} diff --git a/src/main/scala/xyz/driver/core/init/ProtobufApi.scala b/src/main/scala/xyz/driver/core/init/ProtobufApi.scala deleted file mode 100644 index 284ac67..0000000 --- a/src/main/scala/xyz/driver/core/init/ProtobufApi.scala +++ /dev/null @@ -1,7 +0,0 @@ -package xyz.driver.core -package init - -/** Mixin trait for services that implement an API based on Protocol Buffers and gRPC. - * TODO: implement - */ -trait ProtobufApi extends AkkaBootable diff --git a/src/main/scala/xyz/driver/core/init/SimpleHttpApp.scala b/src/main/scala/xyz/driver/core/init/SimpleHttpApp.scala deleted file mode 100644 index 61ca363..0000000 --- a/src/main/scala/xyz/driver/core/init/SimpleHttpApp.scala +++ /dev/null @@ -1,4 +0,0 @@ -package xyz.driver.core -package init - -trait SimpleHttpApp extends AkkaBootable with HttpApi with CloudServices -- cgit v1.2.3 From 220ec2115b8a1b24f4a83a97ce68a4ae5e074509 Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Mon, 17 Sep 2018 15:24:06 -0700 Subject: Upgrade sbt build configuration --- build.sbt | 114 +++++++++++++++++++++++------------------------ project/build.properties | 2 +- project/plugins.sbt | 2 +- 3 files changed, 58 insertions(+), 60 deletions(-) diff --git a/build.sbt b/build.sbt index c57a7ca..90698df 100644 --- a/build.sbt +++ b/build.sbt @@ -1,100 +1,98 @@ import sbt._ import Keys._ -val testdeps = libraryDependencies ++= Seq( +scalacOptions in ThisBuild in (Compile, doc) ++= Seq( + "-groups", // group similar methods together based on the @group annotation. + "-diagrams", // show class hierarchy diagrams (requires 'dot' to be available on path) + "-implicits", // add methods "inherited" through implicit conversions + "-sourcepath", + baseDirectory.value.getAbsolutePath, + "-doc-source-url", + s"https://github.com/drivergroup/driver-core/blob/master€{FILE_PATH}.scala" +) + +// TODO these shouldn't be declared in the build scope. They should be moved to the individual +// sub-projects that actually depend on them. +libraryDependencies in ThisBuild ++= Seq( + // please keep these sorted alphabetically + "ch.qos.logback" % "logback-classic" % "1.2.3", + "ch.qos.logback.contrib" % "logback-jackson" % "0.1.5", + "ch.qos.logback.contrib" % "logback-json-classic" % "0.1.5", + "com.aliyun.mns" % "aliyun-sdk-mns" % "1.1.8", + "com.aliyun.oss" % "aliyun-sdk-oss" % "2.8.2", + "com.amazonaws" % "aws-java-sdk-s3" % "1.11.342", + "com.beachape" %% "enumeratum" % "1.5.13", + "com.github.swagger-akka-http" %% "swagger-akka-http" % "1.0.0", + "com.google.cloud" % "google-cloud-pubsub" % "1.31.0", + "com.google.cloud" % "google-cloud-storage" % "1.31.0", + "com.googlecode.libphonenumber" % "libphonenumber" % "8.9.7", + "com.neovisionaries" % "nv-i18n" % "1.23", + "com.pauldijou" %% "jwt-core" % "0.16.0", + "com.softwaremill.sttp" %% "akka-http-backend" % "1.2.2", + "com.softwaremill.sttp" %% "core" % "1.2.2", + "com.typesafe" % "config" % "1.3.3", + "com.typesafe.akka" %% "akka-actor" % "2.5.14", + "com.typesafe.akka" %% "akka-http-core" % "10.1.4", + "com.typesafe.akka" %% "akka-http-spray-json" % "10.1.4", + "com.typesafe.akka" %% "akka-http-testkit" % "10.1.4", + "com.typesafe.akka" %% "akka-stream" % "2.5.14", + "com.typesafe.scala-logging" %% "scala-logging" % "3.9.0", + "com.typesafe.slick" %% "slick" % "3.2.3", + "eu.timepit" %% "refined" % "0.9.0", + "io.kamon" %% "kamon-akka-2.5" % "1.0.0", + "io.kamon" %% "kamon-core" % "1.1.3", + "io.kamon" %% "kamon-statsd" % "1.0.0", + "io.kamon" %% "kamon-system-metrics" % "1.0.0", + "javax.xml.bind" % "jaxb-api" % "2.2.8", "org.mockito" % "mockito-core" % "1.9.5" % "test", + "org.scala-lang.modules" %% "scala-async" % "0.9.7", "org.scalacheck" %% "scalacheck" % "1.14.0" % "test", "org.scalatest" %% "scalatest" % "3.0.5" % "test", + "org.scalaz" %% "scalaz-core" % "7.2.24", + "xyz.driver" %% "spray-json-derivation" % "0.6.0", + "xyz.driver" %% "tracing" % "0.1.2" ) + lazy val `core-util` = project .enablePlugins(LibraryPlugin) - .settings( - libraryDependencies ++= Seq( - // please keep these sorted alphabetically - "ch.qos.logback" % "logback-classic" % "1.2.3", - "ch.qos.logback.contrib" % "logback-jackson" % "0.1.5", - "ch.qos.logback.contrib" % "logback-json-classic" % "0.1.5", - "com.aliyun.mns" % "aliyun-sdk-mns" % "1.1.8", - "com.aliyun.oss" % "aliyun-sdk-oss" % "2.8.2", - "com.amazonaws" % "aws-java-sdk-s3" % "1.11.342", - "com.beachape" %% "enumeratum" % "1.5.13", - "com.github.swagger-akka-http" %% "swagger-akka-http" % "1.0.0", - "com.google.cloud" % "google-cloud-pubsub" % "1.31.0", - "com.google.cloud" % "google-cloud-storage" % "1.31.0", - "com.googlecode.libphonenumber" % "libphonenumber" % "8.9.7", - "com.neovisionaries" % "nv-i18n" % "1.23", - "com.pauldijou" %% "jwt-core" % "0.16.0", - "com.softwaremill.sttp" %% "akka-http-backend" % "1.2.2", - "com.softwaremill.sttp" %% "core" % "1.2.2", - "com.typesafe" % "config" % "1.3.3", - "com.typesafe.akka" %% "akka-actor" % "2.5.14", - "com.typesafe.akka" %% "akka-http-core" % "10.1.4", - "com.typesafe.akka" %% "akka-http-spray-json" % "10.1.4", - "com.typesafe.akka" %% "akka-http-testkit" % "10.1.4", - "com.typesafe.akka" %% "akka-stream" % "2.5.14", - "com.typesafe.scala-logging" %% "scala-logging" % "3.9.0", - "com.typesafe.slick" %% "slick" % "3.2.3", - "eu.timepit" %% "refined" % "0.9.0", - "io.kamon" %% "kamon-akka-2.5" % "1.0.0", - "io.kamon" %% "kamon-core" % "1.1.3", - "io.kamon" %% "kamon-statsd" % "1.0.0", - "io.kamon" %% "kamon-system-metrics" % "1.0.0", - "javax.xml.bind" % "jaxb-api" % "2.2.8", - "org.scala-lang.modules" %% "scala-async" % "0.9.7", - "org.scalaz" %% "scalaz-core" % "7.2.24", - "xyz.driver" %% "spray-json-derivation" % "0.6.0", - "xyz.driver" %% "tracing" % "0.1.2" - ) - ) lazy val `core-types` = project .enablePlugins(LibraryPlugin) .dependsOn(`core-util`) - .settings(testdeps) lazy val `core-rest` = project .enablePlugins(LibraryPlugin) .dependsOn(`core-util`, `core-types`, `core-reporting`) - .settings(testdeps) lazy val `core-reporting` = project .enablePlugins(LibraryPlugin) .dependsOn(`core-util`) - .settings(testdeps) lazy val `core-storage` = project .enablePlugins(LibraryPlugin) .dependsOn(`core-reporting`) - .settings(testdeps) lazy val `core-messaging` = project .enablePlugins(LibraryPlugin) .dependsOn(`core-reporting`) - .settings(testdeps) lazy val `core-database` = project .enablePlugins(LibraryPlugin) .dependsOn(`core-types`) - .settings(testdeps) lazy val `core-init` = project .enablePlugins(LibraryPlugin) .dependsOn(`core-reporting`, `core-storage`, `core-messaging`, `core-rest`, `core-database`) - .settings(testdeps) lazy val core = project .in(file(".")) .enablePlugins(LibraryPlugin) - .settings( - scalacOptions in (Compile, doc) ++= Seq( - "-groups", // group similar methods together based on the @group annotation. - "-diagrams", // show classs hierarchy diagrams (requires 'dot' to be available on path) - "-implicits", // add methods "inherited" through implicit conversions - "-sourcepath", - baseDirectory.value.getAbsolutePath, - "-doc-source-url", - s"https://github.com/drivergroup/driver-core/blob/master€{FILE_PATH}.scala" - ) + .dependsOn( + `core-types`, + `core-rest`, + `core-reporting`, + `core-storage`, + `core-messaging`, + `core-database`, + `core-init` ) - .dependsOn(`core-types`, `core-rest`, `core-reporting`, `core-storage`, `core-messaging`, `core-database`, `core-init`) - .settings(testdeps) diff --git a/project/build.properties b/project/build.properties index 5620cc5..0cd8b07 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.2.1 +sbt.version=1.2.3 diff --git a/project/plugins.sbt b/project/plugins.sbt index 17a573c..736226d 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1 +1 @@ -addSbtPlugin("xyz.driver" % "sbt-settings" % "2.0.7") +addSbtPlugin("xyz.driver" % "sbt-settings" % "2.0.9") -- cgit v1.2.3 From 32136fa99a67027a8273b635749b40e93998de0c Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Mon, 17 Sep 2018 17:05:23 -0700 Subject: Rewrite README for new layout --- README.md | 257 +++++++++++-------------------------------- documentation/components.dot | 21 ++++ documentation/components.svg | 133 ++++++++++++++++++++++ 3 files changed, 216 insertions(+), 195 deletions(-) create mode 100644 documentation/components.dot create mode 100644 documentation/components.svg diff --git a/README.md b/README.md index a2d3649..c44f820 100644 --- a/README.md +++ b/README.md @@ -7,227 +7,94 @@ cherry-picked onto the [1.x branch](https://github.com/drivergroup/driver-core/tree/1.x). ---- +[![Build Status](https://travis-ci.com/drivergroup/driver-core.svg?token=S4oyfBY3YoEdLmckujJx&branch=master)](https://travis-ci.com/drivergroup/driver-core) -# Driver Core Library for Scala Services [![Build Status](https://travis-ci.com/drivergroup/driver-core.svg?token=S4oyfBY3YoEdLmckujJx&branch=master)](https://travis-ci.com/drivergroup/driver-core) +# Driver Core Library for Scala Services Multi-cloud utilities and application initialization framework. This library offers many common utilities for building applications -that run on multiple environments, including Google Cloud, Ali Cloud, +that run in multiple environments, including Google Cloud, Ali Cloud, and of course on development machines. +## Highlights -# Overview +- Cloud agnostic. -*This section applies to the 1.x series of core. The current development master branch may include changes not described here.* +- Sensible defaults. *Applications that use the initialization + framework can run out-of-the box on cloud or locally, with minimal + config required.* -Core library is used to provide ways to implement practices established in [Driver service template](http://github.com/drivergroup/driver-template) (check its [README.md](https://github.com/drivergroup/driver-template/blob/master/README.md)). +- Distributed tracing built into all major utilities. *Significant + actions are logged and reported automatically to an OpenTrace + aggregator.* -## Components +- Extensible. *Utilities are built to be used standalone. A + trait-based initialization framework provides utility instances for + common use-cases.* - * `core package` provides `Id` and `Name` implementations (with equal and ordering), utils for ScalaZ `OptionT`, and also `make` and `using` functions and `@@` (tagged) type, - * `tagging` Utilities for tagging primitive types for extra type safety, as well as some tags that involve extra transformation upon deserializing with spray, - * `config` Contains method `loadDefaultConfig` with default way of providing config to the application, - * `domain` Common generic domain objects, e.g., `Email` and `PhoneNumber`, - * `messages` Localization messages supporting different locales and methods to read from config, - * `database` Method for database initialization from config, `Id`, `Name`, `Time`, `Date` etc. mapping, schema lifecycle and base classes to implement and test `Repository` (data access objects), - * `rest` Wrapper over call to external REST API, authorization, context headers, XSS protection, does logging and allows to add Swagger to a service, - * `auth` Basic entities for authentication and authorization: `User`, `Role` `Permission` `AuthToken`, `AuthCredentials` etc., - * `swagger` Contains custom `AbstractModelConverter` to customize Swagger JSON with any Scala JSON formats created by, for instance, Spray JSON, - * `json` Json formats for `Id`, `Name`, `Time`, `Revision`, `Email`, `PhoneNumber`, `AuthCredentials` and converters for GADTs, enums and value classes, - * `file` Interface `FileStorage` and file storage implementations with GCS, S3 and local FS, - * `app` Base class for Driver service, which initializes swagger, app modules and its routes. - * `generators` Set of functions to prototype APIs. Combines with `faker` package, - * `stats` Methods to collect system stats: memory, cpu, gc, file system space usage, - * `logging` Custom Driver logging layout (not finished yet). +## Overview -Dependencies of core modules might be found in [Dependencies of the Modules diagram](https://github.com/drivergroup/driver-template/blob/master/Modules%20dependencies.pdf) file in driver-template repository in "Core component dependencies" section. +### Components -## Examples +This project is split into multiple submodules, each providing +specific functionality. -### Functions `make` and `using` -Those functions are especially useful to make procedural legacy Java APIs more functional and make scope of its usage more explicit. Runnable examples of its usage might be found in [`CoreTest`](https://github.com/drivergroup/driver-core/blob/master/src/test/scala/xyz/driver/core/CoreTest.scala), e.g., +Project | Description +-----------------|------------ +`core` | *(deprecated)* Previous application initialization framework. +`core-database` | Utilities for working with databases, slick in particular. +`core-init` | Mini-framework that offers default instances of many utilities, configured for the current platform. +`core-messaging` | Library for abstracting over message buses such as Google PubSub. +`core-reporting` | Combined tracing and logging library. +`core-rest` | Abstractions to represent RESTful services, discovery and client implementations. +`core-storage` | Object storage utilities. +`core-types` | Type definitions that are commonly used by applications. +`core-util` | Other utilities that do not belong anywhere else. **Note that this is a staging place for code that does not have its own submodule yet. Do not depend on it externally!** - useObject(make(new ObjectWithProceduralInitialization) { o => - o.setSetting1(...) // returns Unit - o.setSetting2(...) // returns Unit - o.setSetting3(...) // returns Unit - }) +These components and their internal dependencies can be represented +with the following graph. - // instead of - val o = new ObjectWithProceduralInitialization - o.setSetting1(...) - o.setSetting2(...) - o.setSetting3(...) +![Inter-Component Dependencies](documentation/components.svg) - useObject(o) +### Dependency -and - - using(... open output stream ...) { os => - os.write(...) - } - - // it will be close automatically - -### `OptionT` utils -Before - -``` -OptionT.optionT[Future](service.getRecords(id).map(Option.apply)) -OptionT.optionT(service.doSomething(id).map(_ => Option(()))) - -// Do not want to stop and return `None`, if `produceEffect` returns `None` -for { - x <- service.doSomething(id) - _ <- service.produceEffect(id, x).map(_ => ()).orElse(OptionT.some[Future, Unit](()))) -} yield x -``` - -after - -``` -service.getRecords(id).toOptionT -service.doSomething(id).toUnitOptionT - -// Do not want to stop and return `None` if `produceEffect` returns `None` -for { - x <- service.doSomething(id) - _ <- service.produceEffect(id, x).continueIgnoringNone -} yield x -``` - -### `@@` or Tagged types - -For type definitions, the only import required is - -```scala -import xyz.driver.core.@@ -``` - -which provides just the ability to tag types: `val value: String @@ Important`. Two `String`s with different tags will -be distinguished by the compiler, helping reduce the possibility of mixing values passed into methods with several -arguments of identical types. - -To work with tags in actual values, use the following convenience methods: - -```scala -import xyz.driver.core.tagging._ - -val str = "abc".tagged[Important] -``` - -or go back to plain (say, in case you have an implicit for untagged value) - -```scala -// val trimmedExternalId: String @@ Trimmed = "123" - -Trials.filter(_.externalId === trimmedExternalId.untag) +All components are published together under the same version ([latest +version](https://github.com/drivergroup/driver-core/releases)). +```sbt +libraryDependencies += "xyz.driver" %% "core-" % "" ``` -### `Time` and `TimeProvider` - -**NOTE: The contents of this section has been deprecated - use java.time.Clock instead** - -Usage examples for `Time` (also check [TimeTest](https://github.com/drivergroup/driver-core/blob/master/src/test/scala/xyz/driver/core/TimeTest.scala) for more examples). - - Time(234L).isAfter(Time(123L)) - - Time(123L).isBefore(Time(234L)) - - Time(123L).advanceBy(4 days) - - Seq(Time(321L), Time(123L), Time(231L)).sorted - - startOfMonth(Time(1468937089834L)) should be (Time(1467381889834L)) - - textualDate(Time(1468937089834L)) should be ("July 19, 2016") - - textualTime(Time(1468937089834L)) should be ("Jul 19, 2016 10:04:49 AM") - - TimeRange(Time(321L), Time(432L)).duration should be(111.milliseconds) - - -### Generators -Example of how to generate a case class instance, - - import com.drivergrp.core._ - - Consumer( - generators.nextId[Consumer], - Name[Consumer](faker.Name.name), - faker.Lorem.sentence(word_count = 10)) - - -For more examples check [project tests](https://github.com/drivergroup/driver-core/tree/master/src/test/scala/xyz/driver/core) or [service template](http://github.com/drivergroup/driver-template) repository. - -### App - -To start a new application using standard Driver application class, follow this pattern: - - object MyApp extends App { - - new DriverApp(BuildInfo.version, - BuildInfo.gitHeadCommit.getOrElse("None"), - modules = Seq(myModule1, myModule2), - time, log, config, - interface = "::0", baseUrl, scheme, port) - (servicesActorSystem, servicesExecutionContext).run() - } - -### REST -With REST utils, for instance, you can use the following directives in [akka-http](https://github.com/akka/akka-http) routes, as follows - - sanitizeRequestEntity { // Prevents XSS - serviceContext { implicit ctx => // Extracts context headers from request - authorize(CanSeeUser(userId)) { user => // Authorizes and extracts user - // Your code using `ctx` and `user` - } - } - } - -### Swagger -Swagger JSON formats built using reflection can be overriden by using `CustomSwaggerJsonConverter` at the start of your application initialization in the following way: - - ModelConverters.getInstance() - .addConverter(new CustomSwaggerJsonConverter(Json.mapper(), - CustomSwaggerFormats.customProperties, CustomSwaggerFormats.customObjectsExamples)) - -### Locale -Locale messages can be initialized and used in the following way, - - val localeConfig = config.getConfig("locale") - val log = com.typesafe.scalalogging.Logger(LoggerFactory.getLogger(classOf[MyClass])) - - val messages = xyz.driver.core.messages.Messages.messages(localeConfig, log, Locale.US) - - messages("my.locale.message") - messages("my.locale.message.with.param", parameter) - - -### System stats -Stats it gives access to are, - - xyz.driver.core.stats.SystemStats.memoryUsage - - xyz.driver.core.stats.SystemStats.availableProcessors - - xyz.driver.core.stats.SystemStats.garbageCollectorStats - - xyz.driver.core.stats.SystemStats.fileSystemSpace +### External dependencies - xyz.driver.core.stats.SystemStats.operatingSystemStats +This project has quite a few external dependencies. See +[build.sbt](build.sbt) for a list of them. Some notable Scala +libraries used are: +- [akka-http](https://doc.akka.io/docs/akka-http/current/) +- [akka-stream](https://doc.akka.io/docs/akka/current/stream/) +- [enumeratum](https://github.com/lloydmeta/enumeratum#enumeratum------) +- [scala-async](https://github.com/scala/scala-async) +- [slick](http://slick.lightbend.com/) +- [spray-json](https://github.com/spray/spray-json) +- [sttp](https://github.com/softwaremill/sttp) -## Running +## Example Usage -1. Compile everything and test: +*TODO* - $ sbt test +## Building -2. Publish to local repository, to use changes in depending projects: +This project uses [sbt](https://www.scala-sbt.org/) as build tool. It +has grown organically over time, but all new code should follow the +best practices outlined +[here](https://style.driver.engineering/scala.html). - $ sbt publishLocal +It is set up to automatically build, test, and publish a release when +a Git tag is pushed that matches the regular expression +`v[0-9].*`. Hence, to publish a new version "1.2.3", simply create a +tag "v1.2.3" and `git push --tags` to origin. -3. In order to release a new version of core, merge your PR, tag the HEAD master commit with the next version - (don't forget the "v." prefix) and push tags - Travis will release the artifact automatically! +## Copying +Copyright 2016-2018 Driver Inc. Released under the Apache License, +Version 2.0. diff --git a/documentation/components.dot b/documentation/components.dot new file mode 100644 index 0000000..f00dff3 --- /dev/null +++ b/documentation/components.dot @@ -0,0 +1,21 @@ +digraph core { + + rankdir = BT; + + "core-reporting"; + "core-storage" -> "core-reporting"; + "core-messaging" -> "core-reporting"; + "core-database" -> "core-reporting"; + "core-database" -> "core-types"; + "core-rest" -> "core-reporting"; + "core-rest" -> "core-types"; + "core-init" -> "core-storage"; + "core-init" -> "core-messaging"; + "core-init" -> "core-database"; + "core-init" -> "core-rest"; + "core-init" -> "core-reporting"; + + core [color=red]; + core -> "core-init" [color=red]; + +} diff --git a/documentation/components.svg b/documentation/components.svg new file mode 100644 index 0000000..0531399 --- /dev/null +++ b/documentation/components.svg @@ -0,0 +1,133 @@ + + + + + + +core + + + +core-reporting + +core-reporting + + + +core-storage + +core-storage + + + +core-storage->core-reporting + + + + + +core-messaging + +core-messaging + + + +core-messaging->core-reporting + + + + + +core-database + +core-database + + + +core-database->core-reporting + + + + + +core-types + +core-types + + + +core-database->core-types + + + + + +core-rest + +core-rest + + + +core-rest->core-reporting + + + + + +core-rest->core-types + + + + + +core-init + +core-init + + + +core-init->core-reporting + + + + + +core-init->core-storage + + + + + +core-init->core-messaging + + + + + +core-init->core-database + + + + + +core-init->core-rest + + + + + +core + +core + + + +core->core-init + + + + + -- cgit v1.2.3 From e67f49c20c6901bf77bcfb735254e44dc70c0cee Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Mon, 17 Sep 2018 17:49:59 -0700 Subject: Aggregate all projects in root project --- build.sbt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/build.sbt b/build.sbt index 90698df..fdba59e 100644 --- a/build.sbt +++ b/build.sbt @@ -96,3 +96,13 @@ lazy val core = project `core-database`, `core-init` ) + .aggregate( + `core-types`, + `core-rest`, + `core-reporting`, + `core-storage`, + `core-messaging`, + `core-database`, + `core-init`, + `core-util` + ) -- cgit v1.2.3 From 477804e21c3c61666a48b74f17caef04233c2363 Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Thu, 4 Oct 2018 13:52:14 -0700 Subject: Fix dependencies in tests to accomodate project split --- .../xyz/driver/core/database/DatabaseTest.scala | 1 - .../src/test/scala/xyz/driver/core/AuthTest.scala | 5 +- .../scala/xyz/driver/core/rest/DriverAppTest.scala | 89 ------------- .../xyz/driver/core/rest/DriverRouteTest.scala | 14 +- .../driver/core/storage/AliyunBlobStorage.scala | 17 ++- .../main/scala/xyz/driver/core/deprecations.scala | 5 + .../main/scala/xyz/driver/core/generators.scala | 143 +++++++++++++++++++++ src/test/scala/xyz/driver/core/DriverAppTest.scala | 89 +++++++++++++ 8 files changed, 250 insertions(+), 113 deletions(-) delete mode 100644 core-rest/src/test/scala/xyz/driver/core/rest/DriverAppTest.scala create mode 100644 core-types/src/main/scala/xyz/driver/core/deprecations.scala create mode 100644 core-types/src/main/scala/xyz/driver/core/generators.scala create mode 100644 src/test/scala/xyz/driver/core/DriverAppTest.scala diff --git a/core-database/src/test/scala/xyz/driver/core/database/DatabaseTest.scala b/core-database/src/test/scala/xyz/driver/core/database/DatabaseTest.scala index 8d2a4ac..1fee902 100644 --- a/core-database/src/test/scala/xyz/driver/core/database/DatabaseTest.scala +++ b/core-database/src/test/scala/xyz/driver/core/database/DatabaseTest.scala @@ -2,7 +2,6 @@ package xyz.driver.core.database import org.scalatest.{FlatSpec, Matchers} import org.scalatest.prop.Checkers -import xyz.driver.core.rest.errors.DatabaseException class DatabaseTest extends FlatSpec with Matchers with Checkers { import xyz.driver.core.generators._ diff --git a/core-rest/src/test/scala/xyz/driver/core/AuthTest.scala b/core-rest/src/test/scala/xyz/driver/core/AuthTest.scala index 2e772fb..8c32aa7 100644 --- a/core-rest/src/test/scala/xyz/driver/core/AuthTest.scala +++ b/core-rest/src/test/scala/xyz/driver/core/AuthTest.scala @@ -9,11 +9,12 @@ import akka.http.scaladsl.model.headers.{ import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server._ import akka.http.scaladsl.testkit.ScalatestRouteTest +import com.typesafe.scalalogging.Logger import org.scalatest.{FlatSpec, Matchers} +import org.slf4j.helpers.NOPLogger import pdi.jwt.{Jwt, JwtAlgorithm} import xyz.driver.core.auth._ import xyz.driver.core.domain.Email -import xyz.driver.core.logging._ import xyz.driver.core.rest._ import xyz.driver.core.rest.auth._ import xyz.driver.core.time.Time @@ -53,7 +54,7 @@ class AuthTest extends FlatSpec with Matchers with ScalatestRouteTest { val authorization = new ChainedAuthorization[User](tokenAuthorization, basicAuthorization) - val authStatusService = new AuthProvider[User](authorization, NoLogger) { + val authStatusService = new AuthProvider[User](authorization, Logger(NOPLogger.NOP_LOGGER)) { override def authenticatedUser(implicit ctx: ServiceRequestContext): OptionT[Future, User] = OptionT.optionT[Future] { if (ctx.contextHeaders.keySet.contains(AuthProvider.AuthenticationTokenHeader)) { diff --git a/core-rest/src/test/scala/xyz/driver/core/rest/DriverAppTest.scala b/core-rest/src/test/scala/xyz/driver/core/rest/DriverAppTest.scala deleted file mode 100644 index 324c8d8..0000000 --- a/core-rest/src/test/scala/xyz/driver/core/rest/DriverAppTest.scala +++ /dev/null @@ -1,89 +0,0 @@ -package xyz.driver.core.rest - -import akka.http.scaladsl.model.headers._ -import akka.http.scaladsl.model.{HttpMethod, StatusCodes} -import akka.http.scaladsl.server.{Directives, Route} -import akka.http.scaladsl.testkit.ScalatestRouteTest -import com.typesafe.config.ConfigFactory -import org.scalatest.{AsyncFlatSpec, Matchers} -import xyz.driver.core.app.{DriverApp, SimpleModule} - -class DriverAppTest extends AsyncFlatSpec with ScalatestRouteTest with Matchers with Directives { - val config = ConfigFactory.parseString(""" - |application { - | cors { - | allowedOrigins: ["example.com"] - | } - |} - """.stripMargin).withFallback(ConfigFactory.load) - - val origin = Origin(HttpOrigin("https", Host("example.com"))) - val allowedOrigins = Set(HttpOrigin("https", Host("example.com"))) - val allowedMethods: collection.immutable.Seq[HttpMethod] = { - import akka.http.scaladsl.model.HttpMethods._ - collection.immutable.Seq(GET, PUT, POST, PATCH, DELETE, OPTIONS, TRACE) - } - - import scala.reflect.runtime.universe.typeOf - class TestApp(testRoute: Route) - extends DriverApp( - appName = "test-app", - version = "0.0.1", - gitHash = "deadb33f", - modules = Seq(new SimpleModule("test-module", theRoute = testRoute, routeType = typeOf[DriverApp])), - config = config, - log = xyz.driver.core.logging.NoLogger - ) - - it should "respond with the correct CORS headers for the swagger OPTIONS route" in { - val route = new TestApp(get(complete(StatusCodes.OK))) - Options(s"/api-docs/swagger.json").withHeaders(origin) ~> route.appRoute ~> check { - status shouldBe StatusCodes.OK - headers should contain(`Access-Control-Allow-Origin`(HttpOriginRange(allowedOrigins.toSeq: _*))) - header[`Access-Control-Allow-Methods`].get.methods should contain theSameElementsAs allowedMethods - } - } - - it should "respond with the correct CORS headers for the test route" in { - val route = new TestApp(get(complete(StatusCodes.OK))) - Get(s"/api/v1/test").withHeaders(origin) ~> route.appRoute ~> check { - status shouldBe StatusCodes.OK - headers should contain(`Access-Control-Allow-Origin`(HttpOriginRange(allowedOrigins.toSeq: _*))) - } - } - - it should "respond with the correct CORS headers for a concatenated route" in { - val route = new TestApp(get(complete(StatusCodes.OK)) ~ post(complete(StatusCodes.OK))) - Post(s"/api/v1/test").withHeaders(origin) ~> route.appRoute ~> check { - status shouldBe StatusCodes.OK - headers should contain(`Access-Control-Allow-Origin`(HttpOriginRange(allowedOrigins.toSeq: _*))) - } - } - - it should "allow subdomains of allowed origin suffixes" in { - val route = new TestApp(get(complete(StatusCodes.OK))) - Get(s"/api/v1/test") - .withHeaders(Origin(HttpOrigin("https", Host("foo.example.com")))) ~> route.appRoute ~> check { - status shouldBe StatusCodes.OK - headers should contain(`Access-Control-Allow-Origin`(HttpOrigin("https", Host("foo.example.com")))) - } - } - - it should "respond with default domains for invalid origins" in { - val route = new TestApp(get(complete(StatusCodes.OK))) - Get(s"/api/v1/test") - .withHeaders(Origin(HttpOrigin("https", Host("invalid.foo.bar.com")))) ~> route.appRoute ~> check { - status shouldBe StatusCodes.OK - headers should contain(`Access-Control-Allow-Origin`(HttpOriginRange.*)) - } - } - - it should "respond with Pragma and Cache-Control (no-cache) headers" in { - val route = new TestApp(get(complete(StatusCodes.OK))) - Get(s"/api/v1/test") ~> route.appRoute ~> check { - status shouldBe StatusCodes.OK - header("Pragma").map(_.value()) should contain("no-cache") - header[`Cache-Control`].map(_.value()) should contain("no-cache") - } - } -} diff --git a/core-rest/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala b/core-rest/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala index cc0019a..d1172fa 100644 --- a/core-rest/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala +++ b/core-rest/src/test/scala/xyz/driver/core/rest/DriverRouteTest.scala @@ -8,8 +8,8 @@ import akka.http.scaladsl.server.{Directives, RejectionHandler, Route} import akka.http.scaladsl.testkit.ScalatestRouteTest import com.typesafe.scalalogging.Logger import org.scalatest.{AsyncFlatSpec, Matchers} +import org.slf4j.helpers.NOPLogger import xyz.driver.core.json.serviceExceptionFormat -import xyz.driver.core.logging.NoLogger import xyz.driver.core.rest.errors._ import scala.concurrent.Future @@ -17,7 +17,7 @@ import scala.concurrent.Future class DriverRouteTest extends AsyncFlatSpec with ScalatestRouteTest with SprayJsonSupport with Matchers with Directives { class TestRoute(override val route: Route) extends DriverRoute { - override def log: Logger = NoLogger + override def log: Logger = Logger(NOPLogger.NOP_LOGGER) } "DriverRoute" should "respond with 200 OK for a basic route" in { @@ -94,16 +94,6 @@ class DriverRouteTest } } - it should "respond with a 500 for DatabaseException" in { - val route = new TestRoute(akkaComplete(Future.failed[String](DatabaseException()))) - - Post("/api/v1/foo/bar") ~> route.routeWithDefaults ~> check { - handled shouldBe true - status shouldBe StatusCodes.InternalServerError - responseAs[ServiceException] shouldBe DatabaseException() - } - } - it should "add a `Connection: close` header to avoid clashing with envoy's timeouts" in { val rejectionHandler = RejectionHandler.newBuilder().handleNotFound(complete(StatusCodes.NotFound)).result() val route = new TestRoute(handleRejections(rejectionHandler)((get & path("foo"))(complete("OK")))) diff --git a/core-storage/src/main/scala/xyz/driver/core/storage/AliyunBlobStorage.scala b/core-storage/src/main/scala/xyz/driver/core/storage/AliyunBlobStorage.scala index 7e59df4..fd0e7c6 100644 --- a/core-storage/src/main/scala/xyz/driver/core/storage/AliyunBlobStorage.scala +++ b/core-storage/src/main/scala/xyz/driver/core/storage/AliyunBlobStorage.scala @@ -18,11 +18,11 @@ import scala.concurrent.duration.Duration import scala.concurrent.{ExecutionContext, Future} class AliyunBlobStorage( - client: OSSClient, - bucketId: String, - clock: Clock, - chunkSize: Int = AliyunBlobStorage.DefaultChunkSize)(implicit ec: ExecutionContext) - extends SignedBlobStorage { + client: OSSClient, + bucketId: String, + clock: Clock, + chunkSize: Int = AliyunBlobStorage.DefaultChunkSize)(implicit ec: ExecutionContext) + extends SignedBlobStorage { override def uploadContent(name: String, content: Array[Byte]): Future[String] = Future { client.putObject(bucketId, name, new ByteArrayInputStream(content)) name @@ -61,7 +61,7 @@ class AliyunBlobStorage( Future { client.putObject(bucketId, name, is) Done - }) + }) } override def delete(name: String): Future[String] = Future { @@ -92,8 +92,7 @@ class AliyunBlobStorage( object AliyunBlobStorage { val DefaultChunkSize: Int = 8192 - def apply(config: Config, bucketId: String, clock: Clock)( - implicit ec: ExecutionContext): AliyunBlobStorage = { + def apply(config: Config, bucketId: String, clock: Clock)(implicit ec: ExecutionContext): AliyunBlobStorage = { val clientId = config.getString("storage.aliyun.clientId") val clientSecret = config.getString("storage.aliyun.clientSecret") val endpoint = config.getString("storage.aliyun.endpoint") @@ -101,7 +100,7 @@ object AliyunBlobStorage { } def apply(clientId: String, clientSecret: String, endpoint: String, bucketId: String, clock: Clock)( - implicit ec: ExecutionContext): AliyunBlobStorage = { + implicit ec: ExecutionContext): AliyunBlobStorage = { val client = new OSSClient(endpoint, clientId, clientSecret) new AliyunBlobStorage(client, bucketId, clock) } diff --git a/core-types/src/main/scala/xyz/driver/core/deprecations.scala b/core-types/src/main/scala/xyz/driver/core/deprecations.scala new file mode 100644 index 0000000..401e6c6 --- /dev/null +++ b/core-types/src/main/scala/xyz/driver/core/deprecations.scala @@ -0,0 +1,5 @@ +package xyz.driver.core.rest + +package object errors { + //type DatabaseException = xyz.driver.core.errors.DatabaseException +} diff --git a/core-types/src/main/scala/xyz/driver/core/generators.scala b/core-types/src/main/scala/xyz/driver/core/generators.scala new file mode 100644 index 0000000..d00b6dd --- /dev/null +++ b/core-types/src/main/scala/xyz/driver/core/generators.scala @@ -0,0 +1,143 @@ +package xyz.driver.core + +import enumeratum._ +import java.math.MathContext +import java.time.{Instant, LocalDate, ZoneOffset} +import java.util.UUID + +import xyz.driver.core.time.{Time, TimeOfDay, TimeRange} +import xyz.driver.core.date.{Date, DayOfWeek} + +import scala.reflect.ClassTag +import scala.util.Random +import eu.timepit.refined.refineV +import eu.timepit.refined.api.Refined +import eu.timepit.refined.collection._ + +object generators { + + private val random = new Random + import random._ + private val secureRandom = new java.security.SecureRandom() + + private val DefaultMaxLength = 10 + private val StringLetters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ ".toSet + private val NonAmbigiousCharacters = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + private val Numbers = "0123456789" + + private def nextTokenString(length: Int, chars: IndexedSeq[Char]): String = { + val builder = new StringBuilder + for (_ <- 0 until length) { + builder += chars(secureRandom.nextInt(chars.length)) + } + builder.result() + } + + /** Creates a random invitation token. + * + * This token is meant fo human input and avoids using ambiguous characters such as 'O' and '0'. It + * therefore contains less entropy and is not meant to be used as a cryptographic secret. */ + @deprecated( + "The term 'token' is too generic and security and readability conventions are not well defined. " + + "Services should implement their own version that suits their security requirements.", + "1.11.0" + ) + def nextToken(length: Int): String = nextTokenString(length, NonAmbigiousCharacters) + + @deprecated( + "The term 'token' is too generic and security and readability conventions are not well defined. " + + "Services should implement their own version that suits their security requirements.", + "1.11.0" + ) + def nextNumericToken(length: Int): String = nextTokenString(length, Numbers) + + def nextInt(maxValue: Int, minValue: Int = 0): Int = random.nextInt(maxValue - minValue) + minValue + + def nextBoolean(): Boolean = random.nextBoolean() + + def nextDouble(): Double = random.nextDouble() + + def nextId[T](): Id[T] = Id[T](nextUuid().toString) + + def nextId[T](maxLength: Int): Id[T] = Id[T](nextString(maxLength)) + + def nextNumericId[T](): Id[T] = Id[T](nextLong.abs.toString) + + def nextNumericId[T](maxValue: Int): Id[T] = Id[T](nextInt(maxValue).toString) + + def nextName[T](maxLength: Int = DefaultMaxLength): Name[T] = Name[T](nextString(maxLength)) + + def nextNonEmptyName[T](maxLength: Int = DefaultMaxLength): NonEmptyName[T] = + NonEmptyName[T](nextNonEmptyString(maxLength)) + + def nextUuid(): UUID = java.util.UUID.randomUUID + + def nextRevision[T](): Revision[T] = Revision[T](nextUuid().toString) + + def nextString(maxLength: Int = DefaultMaxLength): String = + (oneOf[Char](StringLetters) +: arrayOf(oneOf[Char](StringLetters), maxLength - 1)).mkString + + def nextNonEmptyString(maxLength: Int = DefaultMaxLength): String Refined NonEmpty = { + refineV[NonEmpty]( + (oneOf[Char](StringLetters) +: arrayOf(oneOf[Char](StringLetters), maxLength - 1)).mkString + ).right.get + } + + def nextOption[T](value: => T): Option[T] = if (nextBoolean()) Option(value) else None + + def nextPair[L, R](left: => L, right: => R): (L, R) = (left, right) + + def nextTriad[F, S, T](first: => F, second: => S, third: => T): (F, S, T) = (first, second, third) + + def nextInstant(): Instant = Instant.ofEpochMilli(math.abs(nextLong() % System.currentTimeMillis)) + + def nextTime(): Time = nextInstant() + + def nextTimeOfDay: TimeOfDay = TimeOfDay(java.time.LocalTime.MIN.plusSeconds(nextLong), java.util.TimeZone.getDefault) + + def nextTimeRange(): TimeRange = { + val oneTime = nextTime() + val anotherTime = nextTime() + + TimeRange( + Time(scala.math.min(oneTime.millis, anotherTime.millis)), + Time(scala.math.max(oneTime.millis, anotherTime.millis))) + } + + def nextDate(): Date = nextTime().toDate(java.util.TimeZone.getTimeZone("UTC")) + + def nextLocalDate(): LocalDate = nextInstant().atZone(ZoneOffset.UTC).toLocalDate + + def nextDayOfWeek(): DayOfWeek = oneOf(DayOfWeek.All) + + def nextBigDecimal(multiplier: Double = 1000000.00, precision: Int = 2): BigDecimal = + BigDecimal(multiplier * nextDouble, new MathContext(precision)) + + def oneOf[T](items: T*): T = oneOf(items.toSet) + + def oneOf[T](items: Set[T]): T = items.toSeq(nextInt(items.size)) + + def oneOf[T <: EnumEntry](enum: Enum[T]): T = oneOf(enum.values: _*) + + def arrayOf[T: ClassTag](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Array[T] = + Array.fill(nextInt(maxLength, minLength))(generator) + + def seqOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Seq[T] = + Seq.fill(nextInt(maxLength, minLength))(generator) + + def vectorOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Vector[T] = + Vector.fill(nextInt(maxLength, minLength))(generator) + + def listOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): List[T] = + List.fill(nextInt(maxLength, minLength))(generator) + + def setOf[T](generator: => T, maxLength: Int = DefaultMaxLength, minLength: Int = 0): Set[T] = + seqOf(generator, maxLength, minLength).toSet + + def mapOf[K, V]( + keyGenerator: => K, + valueGenerator: => V, + maxLength: Int = DefaultMaxLength, + minLength: Int = 0): Map[K, V] = + seqOf(nextPair(keyGenerator, valueGenerator), maxLength, minLength).toMap +} diff --git a/src/test/scala/xyz/driver/core/DriverAppTest.scala b/src/test/scala/xyz/driver/core/DriverAppTest.scala new file mode 100644 index 0000000..986e5d3 --- /dev/null +++ b/src/test/scala/xyz/driver/core/DriverAppTest.scala @@ -0,0 +1,89 @@ +package xyz.driver.core + +import akka.http.scaladsl.model.headers._ +import akka.http.scaladsl.model.{HttpMethod, StatusCodes} +import akka.http.scaladsl.server.{Directives, Route} +import akka.http.scaladsl.testkit.ScalatestRouteTest +import com.typesafe.config.ConfigFactory +import org.scalatest.{AsyncFlatSpec, Matchers} +import xyz.driver.core.app.{DriverApp, SimpleModule} + +class DriverAppTest extends AsyncFlatSpec with ScalatestRouteTest with Matchers with Directives { + val config = ConfigFactory.parseString(""" + |application { + | cors { + | allowedOrigins: ["example.com"] + | } + |} + """.stripMargin).withFallback(ConfigFactory.load) + + val origin = Origin(HttpOrigin("https", Host("example.com"))) + val allowedOrigins = Set(HttpOrigin("https", Host("example.com"))) + val allowedMethods: collection.immutable.Seq[HttpMethod] = { + import akka.http.scaladsl.model.HttpMethods._ + collection.immutable.Seq(GET, PUT, POST, PATCH, DELETE, OPTIONS, TRACE) + } + + import scala.reflect.runtime.universe.typeOf + class TestApp(testRoute: Route) + extends DriverApp( + appName = "test-app", + version = "0.0.1", + gitHash = "deadb33f", + modules = Seq(new SimpleModule("test-module", theRoute = testRoute, routeType = typeOf[DriverApp])), + config = config, + log = xyz.driver.core.logging.NoLogger + ) + + it should "respond with the correct CORS headers for the swagger OPTIONS route" in { + val route = new TestApp(get(complete(StatusCodes.OK))) + Options(s"/api-docs/swagger.json").withHeaders(origin) ~> route.appRoute ~> check { + status shouldBe StatusCodes.OK + headers should contain(`Access-Control-Allow-Origin`(HttpOriginRange(allowedOrigins.toSeq: _*))) + header[`Access-Control-Allow-Methods`].get.methods should contain theSameElementsAs allowedMethods + } + } + + it should "respond with the correct CORS headers for the test route" in { + val route = new TestApp(get(complete(StatusCodes.OK))) + Get(s"/api/v1/test").withHeaders(origin) ~> route.appRoute ~> check { + status shouldBe StatusCodes.OK + headers should contain(`Access-Control-Allow-Origin`(HttpOriginRange(allowedOrigins.toSeq: _*))) + } + } + + it should "respond with the correct CORS headers for a concatenated route" in { + val route = new TestApp(get(complete(StatusCodes.OK)) ~ post(complete(StatusCodes.OK))) + Post(s"/api/v1/test").withHeaders(origin) ~> route.appRoute ~> check { + status shouldBe StatusCodes.OK + headers should contain(`Access-Control-Allow-Origin`(HttpOriginRange(allowedOrigins.toSeq: _*))) + } + } + + it should "allow subdomains of allowed origin suffixes" in { + val route = new TestApp(get(complete(StatusCodes.OK))) + Get(s"/api/v1/test") + .withHeaders(Origin(HttpOrigin("https", Host("foo.example.com")))) ~> route.appRoute ~> check { + status shouldBe StatusCodes.OK + headers should contain(`Access-Control-Allow-Origin`(HttpOrigin("https", Host("foo.example.com")))) + } + } + + it should "respond with default domains for invalid origins" in { + val route = new TestApp(get(complete(StatusCodes.OK))) + Get(s"/api/v1/test") + .withHeaders(Origin(HttpOrigin("https", Host("invalid.foo.bar.com")))) ~> route.appRoute ~> check { + status shouldBe StatusCodes.OK + headers should contain(`Access-Control-Allow-Origin`(HttpOriginRange.*)) + } + } + + it should "respond with Pragma and Cache-Control (no-cache) headers" in { + val route = new TestApp(get(complete(StatusCodes.OK))) + Get(s"/api/v1/test") ~> route.appRoute ~> check { + status shouldBe StatusCodes.OK + header("Pragma").map(_.value()) should contain("no-cache") + header[`Cache-Control`].map(_.value()) should contain("no-cache") + } + } +} -- cgit v1.2.3 From 0fbd0fd2585226d3725327afc3e664716c29045f Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Thu, 4 Oct 2018 15:10:55 -0700 Subject: Move remaining swagger utility to rest project --- .../src/main/scala/xyz/driver/core/swagger.scala | 164 +++++++++++++++++++++ src/main/scala/xyz/driver/core/swagger.scala | 164 --------------------- 2 files changed, 164 insertions(+), 164 deletions(-) create mode 100644 core-rest/src/main/scala/xyz/driver/core/swagger.scala delete mode 100644 src/main/scala/xyz/driver/core/swagger.scala diff --git a/core-rest/src/main/scala/xyz/driver/core/swagger.scala b/core-rest/src/main/scala/xyz/driver/core/swagger.scala new file mode 100644 index 0000000..0c1e15d --- /dev/null +++ b/core-rest/src/main/scala/xyz/driver/core/swagger.scala @@ -0,0 +1,164 @@ +package xyz.driver.core + +import java.lang.annotation.Annotation +import java.lang.reflect.Type +import java.util + +import com.fasterxml.jackson.databind.{BeanDescription, ObjectMapper} +import com.fasterxml.jackson.databind.`type`.ReferenceType +import io.swagger.converter._ +import io.swagger.jackson.AbstractModelConverter +import io.swagger.models.{Model, ModelImpl} +import io.swagger.models.properties._ +import io.swagger.util.{Json, PrimitiveType} +import spray.json._ + +object swagger { + + def configureCustomSwaggerModels( + customPropertiesExamples: Map[Class[_], Property], + customObjectsExamples: Map[Class[_], JsValue]) = { + ModelConverters + .getInstance() + .addConverter(new CustomSwaggerJsonConverter(Json.mapper(), customPropertiesExamples, customObjectsExamples)) + } + + object CustomSwaggerJsonConverter { + + def stringProperty(pattern: Option[String] = None, example: Option[String] = None): Property = { + make(new StringProperty()) { sp => + sp.required(true) + example.foreach(sp.example) + pattern.foreach(sp.pattern) + } + } + + def enumProperty[V](values: V*): Property = { + make(new StringProperty()) { sp => + for (v <- values) sp._enum(v.toString) + sp.setRequired(true) + } + } + + def numericProperty(example: Option[AnyRef] = None): Property = { + make(PrimitiveType.DECIMAL.createProperty()) { dp => + dp.setRequired(true) + example.foreach(dp.setExample) + } + } + + def booleanProperty(): Property = { + make(new BooleanProperty()) { bp => + bp.setRequired(true) + } + } + } + + @SuppressWarnings(Array("org.wartremover.warts.Null")) + class CustomSwaggerJsonConverter( + mapper: ObjectMapper, + customProperties: Map[Class[_], Property], + customObjects: Map[Class[_], JsValue]) + extends AbstractModelConverter(mapper) { + import CustomSwaggerJsonConverter._ + + override def resolveProperty( + `type`: Type, + context: ModelConverterContext, + annotations: Array[Annotation], + chain: util.Iterator[ModelConverter]): Property = { + val javaType = Json.mapper().constructType(`type`) + + Option(javaType.getRawClass) + .flatMap { cls => + customProperties.get(cls) + } + .orElse { + `type` match { + case rt: ReferenceType if isOption(javaType.getRawClass) && chain.hasNext => + val nextType = rt.getContentType + val nextResolved = Option(resolveProperty(nextType, context, annotations, chain)).getOrElse( + chain.next().resolveProperty(nextType, context, annotations, chain)) + nextResolved.setRequired(false) + Option(nextResolved) + case t if chain.hasNext => + val nextResolved = chain.next().resolveProperty(t, context, annotations, chain) + nextResolved.setRequired(true) + Option(nextResolved) + case _ => + Option.empty[Property] + } + } + .orNull + } + + @SuppressWarnings(Array("org.wartremover.warts.Null")) + override def resolve(`type`: Type, context: ModelConverterContext, chain: util.Iterator[ModelConverter]): Model = { + + val javaType = Json.mapper().constructType(`type`) + + (getEnumerationInstance(javaType.getRawClass) match { + case Some(_) => Option.empty[Model] // ignore scala enums + case None => + val customObjectModel = customObjects.get(javaType.getRawClass).map { objectExampleJson => + val properties = objectExampleJson.asJsObject.fields.mapValues(parseJsonValueToSwaggerProperty).flatMap { + case (key, value) => value.map(v => key -> v) + } + + val beanDesc = _mapper.getSerializationConfig.introspect[BeanDescription](javaType) + val name = _typeName(javaType, beanDesc) + + make(new ModelImpl()) { model => + model.name(name) + properties.foreach { case (field, property) => model.addProperty(field, property) } + } + } + + customObjectModel.orElse { + if (chain.hasNext) { + val next = chain.next() + Option(next.resolve(`type`, context, chain)) + } else { + Option.empty[Model] + } + } + }).orNull + } + + private def parseJsonValueToSwaggerProperty(jsValue: JsValue): Option[Property] = { + import scala.collection.JavaConverters._ + + jsValue match { + case JsArray(elements) => + elements.headOption.flatMap(parseJsonValueToSwaggerProperty).map { itemProperty => + new ArrayProperty(itemProperty) + } + case JsObject(subFields) => + val subProperties = subFields.mapValues(parseJsonValueToSwaggerProperty).flatMap { + case (key, value) => value.map(v => key -> v) + } + Option(new ObjectProperty(subProperties.asJava)) + case JsBoolean(_) => Option(booleanProperty()) + case JsNumber(value) => Option(numericProperty(example = Option(value))) + case JsString(value) => Option(stringProperty(example = Option(value))) + case _ => Option.empty[Property] + } + } + + private def getEnumerationInstance(cls: Class[_]): Option[Enumeration] = { + if (cls.getFields.map(_.getName).contains("MODULE$")) { + val javaUniverse = scala.reflect.runtime.universe + val m = javaUniverse.runtimeMirror(Thread.currentThread().getContextClassLoader) + val moduleMirror = m.reflectModule(m.staticModule(cls.getName)) + moduleMirror.instance match { + case enumInstance: Enumeration => Some(enumInstance) + case _ => None + } + } else { + None + } + } + + private def isOption(cls: Class[_]): Boolean = cls.equals(classOf[scala.Option[_]]) + } +} diff --git a/src/main/scala/xyz/driver/core/swagger.scala b/src/main/scala/xyz/driver/core/swagger.scala deleted file mode 100644 index 0c1e15d..0000000 --- a/src/main/scala/xyz/driver/core/swagger.scala +++ /dev/null @@ -1,164 +0,0 @@ -package xyz.driver.core - -import java.lang.annotation.Annotation -import java.lang.reflect.Type -import java.util - -import com.fasterxml.jackson.databind.{BeanDescription, ObjectMapper} -import com.fasterxml.jackson.databind.`type`.ReferenceType -import io.swagger.converter._ -import io.swagger.jackson.AbstractModelConverter -import io.swagger.models.{Model, ModelImpl} -import io.swagger.models.properties._ -import io.swagger.util.{Json, PrimitiveType} -import spray.json._ - -object swagger { - - def configureCustomSwaggerModels( - customPropertiesExamples: Map[Class[_], Property], - customObjectsExamples: Map[Class[_], JsValue]) = { - ModelConverters - .getInstance() - .addConverter(new CustomSwaggerJsonConverter(Json.mapper(), customPropertiesExamples, customObjectsExamples)) - } - - object CustomSwaggerJsonConverter { - - def stringProperty(pattern: Option[String] = None, example: Option[String] = None): Property = { - make(new StringProperty()) { sp => - sp.required(true) - example.foreach(sp.example) - pattern.foreach(sp.pattern) - } - } - - def enumProperty[V](values: V*): Property = { - make(new StringProperty()) { sp => - for (v <- values) sp._enum(v.toString) - sp.setRequired(true) - } - } - - def numericProperty(example: Option[AnyRef] = None): Property = { - make(PrimitiveType.DECIMAL.createProperty()) { dp => - dp.setRequired(true) - example.foreach(dp.setExample) - } - } - - def booleanProperty(): Property = { - make(new BooleanProperty()) { bp => - bp.setRequired(true) - } - } - } - - @SuppressWarnings(Array("org.wartremover.warts.Null")) - class CustomSwaggerJsonConverter( - mapper: ObjectMapper, - customProperties: Map[Class[_], Property], - customObjects: Map[Class[_], JsValue]) - extends AbstractModelConverter(mapper) { - import CustomSwaggerJsonConverter._ - - override def resolveProperty( - `type`: Type, - context: ModelConverterContext, - annotations: Array[Annotation], - chain: util.Iterator[ModelConverter]): Property = { - val javaType = Json.mapper().constructType(`type`) - - Option(javaType.getRawClass) - .flatMap { cls => - customProperties.get(cls) - } - .orElse { - `type` match { - case rt: ReferenceType if isOption(javaType.getRawClass) && chain.hasNext => - val nextType = rt.getContentType - val nextResolved = Option(resolveProperty(nextType, context, annotations, chain)).getOrElse( - chain.next().resolveProperty(nextType, context, annotations, chain)) - nextResolved.setRequired(false) - Option(nextResolved) - case t if chain.hasNext => - val nextResolved = chain.next().resolveProperty(t, context, annotations, chain) - nextResolved.setRequired(true) - Option(nextResolved) - case _ => - Option.empty[Property] - } - } - .orNull - } - - @SuppressWarnings(Array("org.wartremover.warts.Null")) - override def resolve(`type`: Type, context: ModelConverterContext, chain: util.Iterator[ModelConverter]): Model = { - - val javaType = Json.mapper().constructType(`type`) - - (getEnumerationInstance(javaType.getRawClass) match { - case Some(_) => Option.empty[Model] // ignore scala enums - case None => - val customObjectModel = customObjects.get(javaType.getRawClass).map { objectExampleJson => - val properties = objectExampleJson.asJsObject.fields.mapValues(parseJsonValueToSwaggerProperty).flatMap { - case (key, value) => value.map(v => key -> v) - } - - val beanDesc = _mapper.getSerializationConfig.introspect[BeanDescription](javaType) - val name = _typeName(javaType, beanDesc) - - make(new ModelImpl()) { model => - model.name(name) - properties.foreach { case (field, property) => model.addProperty(field, property) } - } - } - - customObjectModel.orElse { - if (chain.hasNext) { - val next = chain.next() - Option(next.resolve(`type`, context, chain)) - } else { - Option.empty[Model] - } - } - }).orNull - } - - private def parseJsonValueToSwaggerProperty(jsValue: JsValue): Option[Property] = { - import scala.collection.JavaConverters._ - - jsValue match { - case JsArray(elements) => - elements.headOption.flatMap(parseJsonValueToSwaggerProperty).map { itemProperty => - new ArrayProperty(itemProperty) - } - case JsObject(subFields) => - val subProperties = subFields.mapValues(parseJsonValueToSwaggerProperty).flatMap { - case (key, value) => value.map(v => key -> v) - } - Option(new ObjectProperty(subProperties.asJava)) - case JsBoolean(_) => Option(booleanProperty()) - case JsNumber(value) => Option(numericProperty(example = Option(value))) - case JsString(value) => Option(stringProperty(example = Option(value))) - case _ => Option.empty[Property] - } - } - - private def getEnumerationInstance(cls: Class[_]): Option[Enumeration] = { - if (cls.getFields.map(_.getName).contains("MODULE$")) { - val javaUniverse = scala.reflect.runtime.universe - val m = javaUniverse.runtimeMirror(Thread.currentThread().getContextClassLoader) - val moduleMirror = m.reflectModule(m.staticModule(cls.getName)) - moduleMirror.instance match { - case enumInstance: Enumeration => Some(enumInstance) - case _ => None - } - } else { - None - } - } - - private def isOption(cls: Class[_]): Boolean = cls.equals(classOf[scala.Option[_]]) - } -} -- cgit v1.2.3 From 59007a478595cb0876a65cd603f36d4b870c5c3d Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Thu, 4 Oct 2018 15:25:44 -0700 Subject: Move database exception to core-types --- core-database/src/main/scala/xyz/driver/core/database/Converters.scala | 3 ++- .../src/test/scala/xyz/driver/core/database/DatabaseTest.scala | 1 + core-types/src/main/scala/xyz/driver/core/DatabaseException.scala | 3 +++ 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 core-types/src/main/scala/xyz/driver/core/DatabaseException.scala diff --git a/core-database/src/main/scala/xyz/driver/core/database/Converters.scala b/core-database/src/main/scala/xyz/driver/core/database/Converters.scala index b0054ad..26b6eea 100644 --- a/core-database/src/main/scala/xyz/driver/core/database/Converters.scala +++ b/core-database/src/main/scala/xyz/driver/core/database/Converters.scala @@ -1,5 +1,7 @@ package xyz.driver.core.database +import xyz.driver.core.DatabaseException + import scala.reflect.ClassTag /** @@ -22,4 +24,3 @@ trait Converters { query.map(expectValid[ADT](mapper, _)) } } -final case class DatabaseException(message: String = "Database access error") extends RuntimeException(message) diff --git a/core-database/src/test/scala/xyz/driver/core/database/DatabaseTest.scala b/core-database/src/test/scala/xyz/driver/core/database/DatabaseTest.scala index 1fee902..3407d64 100644 --- a/core-database/src/test/scala/xyz/driver/core/database/DatabaseTest.scala +++ b/core-database/src/test/scala/xyz/driver/core/database/DatabaseTest.scala @@ -2,6 +2,7 @@ package xyz.driver.core.database import org.scalatest.{FlatSpec, Matchers} import org.scalatest.prop.Checkers +import xyz.driver.core.DatabaseException class DatabaseTest extends FlatSpec with Matchers with Checkers { import xyz.driver.core.generators._ diff --git a/core-types/src/main/scala/xyz/driver/core/DatabaseException.scala b/core-types/src/main/scala/xyz/driver/core/DatabaseException.scala new file mode 100644 index 0000000..3babf5c --- /dev/null +++ b/core-types/src/main/scala/xyz/driver/core/DatabaseException.scala @@ -0,0 +1,3 @@ +package xyz.driver.core + +final case class DatabaseException(message: String = "Database access error") extends RuntimeException(message) -- cgit v1.2.3 From d858e1ca733407aeeb39d9d85edb26373443a9b9 Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Fri, 5 Oct 2018 11:43:02 -0700 Subject: Move resources to corresponding subprojects --- core-init/src/main/resources/deployed-logback.xml | 86 + core-init/src/main/resources/logback-test.xml | 45 + core-init/src/main/resources/logback.xml | 69 + core-init/src/main/resources/reference.conf | 26 + core-rest/src/main/resources/reference.conf | 54 + .../src/main/resources/swagger-ui-dist/README.md | 22 + .../resources/swagger-ui-dist/absolute-path.js | 14 + .../resources/swagger-ui-dist/favicon-16x16.png | Bin 0 -> 445 bytes .../resources/swagger-ui-dist/favicon-32x32.png | Bin 0 -> 1141 bytes .../src/main/resources/swagger-ui-dist/index.html | 60 + .../src/main/resources/swagger-ui-dist/index.js | 17 + .../resources/swagger-ui-dist/oauth2-redirect.html | 67 + .../main/resources/swagger-ui-dist/package.json | 72 + .../resources/swagger-ui-dist/swagger-ui-bundle.js | 93 + .../swagger-ui-dist/swagger-ui-bundle.js.map | 1 + .../swagger-ui-standalone-preset.js | 14 + .../swagger-ui-standalone-preset.js.map | 1 + .../main/resources/swagger-ui-dist/swagger-ui.css | 3 + .../resources/swagger-ui-dist/swagger-ui.css.map | 1 + .../main/resources/swagger-ui-dist/swagger-ui.js | 9 + .../resources/swagger-ui-dist/swagger-ui.js.map | 1 + .../src/main/resources/swagger-ui/.gitattributes | 2 + .../src/main/resources/swagger-ui/css/print.css | 1155 + .../src/main/resources/swagger-ui/css/reset.css | 125 + .../src/main/resources/swagger-ui/css/screen.css | 1257 ++ .../main/resources/swagger-ui/css/typography.css | 26 + .../swagger-ui/fonts/droid-sans-v6-latin-700.eot | Bin 0 -> 22922 bytes .../swagger-ui/fonts/droid-sans-v6-latin-700.svg | 411 + .../swagger-ui/fonts/droid-sans-v6-latin-700.ttf | Bin 0 -> 40513 bytes .../swagger-ui/fonts/droid-sans-v6-latin-700.woff | Bin 0 -> 25992 bytes .../swagger-ui/fonts/droid-sans-v6-latin-700.woff2 | Bin 0 -> 11480 bytes .../fonts/droid-sans-v6-latin-regular.eot | Bin 0 -> 22008 bytes .../fonts/droid-sans-v6-latin-regular.svg | 403 + .../fonts/droid-sans-v6-latin-regular.ttf | Bin 0 -> 39069 bytes .../fonts/droid-sans-v6-latin-regular.woff | Bin 0 -> 24868 bytes .../fonts/droid-sans-v6-latin-regular.woff2 | Bin 0 -> 11304 bytes .../resources/swagger-ui/images/explorer_icons.png | Bin 0 -> 5763 bytes .../resources/swagger-ui/images/favicon-16x16.png | Bin 0 -> 645 bytes .../resources/swagger-ui/images/favicon-32x32.png | Bin 0 -> 1654 bytes .../main/resources/swagger-ui/images/favicon.ico | Bin 0 -> 5430 bytes .../resources/swagger-ui/images/logo_small.png | Bin 0 -> 770 bytes .../resources/swagger-ui/images/pet_store_api.png | Bin 0 -> 824 bytes .../main/resources/swagger-ui/images/throbber.gif | Bin 0 -> 9257 bytes .../resources/swagger-ui/images/wordnik_api.png | Bin 0 -> 980 bytes core-rest/src/main/resources/swagger-ui/index.html | 97 + .../main/resources/swagger-ui/lib/backbone-min.js | 15 + .../resources/swagger-ui/lib/handlebars-2.0.0.js | 28 + .../resources/swagger-ui/lib/highlight.7.3.pack.js | 1 + .../resources/swagger-ui/lib/jquery-1.8.0.min.js | 2 + .../resources/swagger-ui/lib/jquery.ba-bbq.min.js | 18 + .../resources/swagger-ui/lib/jquery.slideto.min.js | 1 + .../resources/swagger-ui/lib/jquery.wiggle.min.js | 8 + .../src/main/resources/swagger-ui/lib/marked.js | 1272 ++ .../main/resources/swagger-ui/lib/swagger-oauth.js | 284 + .../resources/swagger-ui/lib/underscore-min.js | 6 + .../resources/swagger-ui/lib/underscore-min.map | 1 + core-rest/src/main/resources/swagger-ui/o2c.html | 20 + .../src/main/resources/swagger-ui/swagger-ui.js | 21740 +++++++++++++++++++ .../main/resources/swagger-ui/swagger-ui.min.js | 8 + src/main/resources/deployed-logback.xml | 86 - src/main/resources/logback-test.xml | 45 - src/main/resources/logback.xml | 69 - src/main/resources/reference.conf | 81 - src/main/resources/swagger-ui-dist/README.md | 22 - .../resources/swagger-ui-dist/absolute-path.js | 14 - .../resources/swagger-ui-dist/favicon-16x16.png | Bin 445 -> 0 bytes .../resources/swagger-ui-dist/favicon-32x32.png | Bin 1141 -> 0 bytes src/main/resources/swagger-ui-dist/index.html | 60 - src/main/resources/swagger-ui-dist/index.js | 17 - .../resources/swagger-ui-dist/oauth2-redirect.html | 67 - src/main/resources/swagger-ui-dist/package.json | 72 - .../resources/swagger-ui-dist/swagger-ui-bundle.js | 93 - .../swagger-ui-dist/swagger-ui-bundle.js.map | 1 - .../swagger-ui-standalone-preset.js | 14 - .../swagger-ui-standalone-preset.js.map | 1 - src/main/resources/swagger-ui-dist/swagger-ui.css | 3 - .../resources/swagger-ui-dist/swagger-ui.css.map | 1 - src/main/resources/swagger-ui-dist/swagger-ui.js | 9 - .../resources/swagger-ui-dist/swagger-ui.js.map | 1 - src/main/resources/swagger-ui/.gitattributes | 2 - src/main/resources/swagger-ui/css/print.css | 1155 - src/main/resources/swagger-ui/css/reset.css | 125 - src/main/resources/swagger-ui/css/screen.css | 1257 -- src/main/resources/swagger-ui/css/typography.css | 26 - .../swagger-ui/fonts/droid-sans-v6-latin-700.eot | Bin 22922 -> 0 bytes .../swagger-ui/fonts/droid-sans-v6-latin-700.svg | 411 - .../swagger-ui/fonts/droid-sans-v6-latin-700.ttf | Bin 40513 -> 0 bytes .../swagger-ui/fonts/droid-sans-v6-latin-700.woff | Bin 25992 -> 0 bytes .../swagger-ui/fonts/droid-sans-v6-latin-700.woff2 | Bin 11480 -> 0 bytes .../fonts/droid-sans-v6-latin-regular.eot | Bin 22008 -> 0 bytes .../fonts/droid-sans-v6-latin-regular.svg | 403 - .../fonts/droid-sans-v6-latin-regular.ttf | Bin 39069 -> 0 bytes .../fonts/droid-sans-v6-latin-regular.woff | Bin 24868 -> 0 bytes .../fonts/droid-sans-v6-latin-regular.woff2 | Bin 11304 -> 0 bytes .../resources/swagger-ui/images/explorer_icons.png | Bin 5763 -> 0 bytes .../resources/swagger-ui/images/favicon-16x16.png | Bin 645 -> 0 bytes .../resources/swagger-ui/images/favicon-32x32.png | Bin 1654 -> 0 bytes src/main/resources/swagger-ui/images/favicon.ico | Bin 5430 -> 0 bytes .../resources/swagger-ui/images/logo_small.png | Bin 770 -> 0 bytes .../resources/swagger-ui/images/pet_store_api.png | Bin 824 -> 0 bytes src/main/resources/swagger-ui/images/throbber.gif | Bin 9257 -> 0 bytes .../resources/swagger-ui/images/wordnik_api.png | Bin 980 -> 0 bytes src/main/resources/swagger-ui/index.html | 97 - src/main/resources/swagger-ui/lib/backbone-min.js | 15 - .../resources/swagger-ui/lib/handlebars-2.0.0.js | 28 - .../resources/swagger-ui/lib/highlight.7.3.pack.js | 1 - .../resources/swagger-ui/lib/jquery-1.8.0.min.js | 2 - .../resources/swagger-ui/lib/jquery.ba-bbq.min.js | 18 - .../resources/swagger-ui/lib/jquery.slideto.min.js | 1 - .../resources/swagger-ui/lib/jquery.wiggle.min.js | 8 - src/main/resources/swagger-ui/lib/marked.js | 1272 -- src/main/resources/swagger-ui/lib/swagger-oauth.js | 284 - .../resources/swagger-ui/lib/underscore-min.js | 6 - .../resources/swagger-ui/lib/underscore-min.map | 1 - src/main/resources/swagger-ui/o2c.html | 20 - src/main/resources/swagger-ui/swagger-ui.js | 21740 ------------------- src/main/resources/swagger-ui/swagger-ui.min.js | 8 - 117 files changed, 27535 insertions(+), 27536 deletions(-) create mode 100644 core-init/src/main/resources/deployed-logback.xml create mode 100644 core-init/src/main/resources/logback-test.xml create mode 100644 core-init/src/main/resources/logback.xml create mode 100644 core-init/src/main/resources/reference.conf create mode 100644 core-rest/src/main/resources/reference.conf create mode 100644 core-rest/src/main/resources/swagger-ui-dist/README.md create mode 100644 core-rest/src/main/resources/swagger-ui-dist/absolute-path.js create mode 100644 core-rest/src/main/resources/swagger-ui-dist/favicon-16x16.png create mode 100644 core-rest/src/main/resources/swagger-ui-dist/favicon-32x32.png create mode 100644 core-rest/src/main/resources/swagger-ui-dist/index.html create mode 100644 core-rest/src/main/resources/swagger-ui-dist/index.js create mode 100644 core-rest/src/main/resources/swagger-ui-dist/oauth2-redirect.html create mode 100644 core-rest/src/main/resources/swagger-ui-dist/package.json create mode 100644 core-rest/src/main/resources/swagger-ui-dist/swagger-ui-bundle.js create mode 100644 core-rest/src/main/resources/swagger-ui-dist/swagger-ui-bundle.js.map create mode 100644 core-rest/src/main/resources/swagger-ui-dist/swagger-ui-standalone-preset.js create mode 100644 core-rest/src/main/resources/swagger-ui-dist/swagger-ui-standalone-preset.js.map create mode 100644 core-rest/src/main/resources/swagger-ui-dist/swagger-ui.css create mode 100644 core-rest/src/main/resources/swagger-ui-dist/swagger-ui.css.map create mode 100644 core-rest/src/main/resources/swagger-ui-dist/swagger-ui.js create mode 100644 core-rest/src/main/resources/swagger-ui-dist/swagger-ui.js.map create mode 100644 core-rest/src/main/resources/swagger-ui/.gitattributes create mode 100644 core-rest/src/main/resources/swagger-ui/css/print.css create mode 100644 core-rest/src/main/resources/swagger-ui/css/reset.css create mode 100644 core-rest/src/main/resources/swagger-ui/css/screen.css create mode 100644 core-rest/src/main/resources/swagger-ui/css/typography.css create mode 100644 core-rest/src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-700.eot create mode 100644 core-rest/src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-700.svg create mode 100644 core-rest/src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-700.ttf create mode 100644 core-rest/src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-700.woff create mode 100644 core-rest/src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-700.woff2 create mode 100644 core-rest/src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-regular.eot create mode 100644 core-rest/src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-regular.svg create mode 100644 core-rest/src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-regular.ttf create mode 100644 core-rest/src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-regular.woff create mode 100644 core-rest/src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-regular.woff2 create mode 100644 core-rest/src/main/resources/swagger-ui/images/explorer_icons.png create mode 100644 core-rest/src/main/resources/swagger-ui/images/favicon-16x16.png create mode 100644 core-rest/src/main/resources/swagger-ui/images/favicon-32x32.png create mode 100644 core-rest/src/main/resources/swagger-ui/images/favicon.ico create mode 100644 core-rest/src/main/resources/swagger-ui/images/logo_small.png create mode 100644 core-rest/src/main/resources/swagger-ui/images/pet_store_api.png create mode 100644 core-rest/src/main/resources/swagger-ui/images/throbber.gif create mode 100644 core-rest/src/main/resources/swagger-ui/images/wordnik_api.png create mode 100644 core-rest/src/main/resources/swagger-ui/index.html create mode 100644 core-rest/src/main/resources/swagger-ui/lib/backbone-min.js create mode 100644 core-rest/src/main/resources/swagger-ui/lib/handlebars-2.0.0.js create mode 100644 core-rest/src/main/resources/swagger-ui/lib/highlight.7.3.pack.js create mode 100644 core-rest/src/main/resources/swagger-ui/lib/jquery-1.8.0.min.js create mode 100644 core-rest/src/main/resources/swagger-ui/lib/jquery.ba-bbq.min.js create mode 100644 core-rest/src/main/resources/swagger-ui/lib/jquery.slideto.min.js create mode 100644 core-rest/src/main/resources/swagger-ui/lib/jquery.wiggle.min.js create mode 100644 core-rest/src/main/resources/swagger-ui/lib/marked.js create mode 100644 core-rest/src/main/resources/swagger-ui/lib/swagger-oauth.js create mode 100644 core-rest/src/main/resources/swagger-ui/lib/underscore-min.js create mode 100644 core-rest/src/main/resources/swagger-ui/lib/underscore-min.map create mode 100644 core-rest/src/main/resources/swagger-ui/o2c.html create mode 100644 core-rest/src/main/resources/swagger-ui/swagger-ui.js create mode 100644 core-rest/src/main/resources/swagger-ui/swagger-ui.min.js delete mode 100644 src/main/resources/deployed-logback.xml delete mode 100644 src/main/resources/logback-test.xml delete mode 100644 src/main/resources/logback.xml delete mode 100644 src/main/resources/reference.conf delete mode 100644 src/main/resources/swagger-ui-dist/README.md delete mode 100644 src/main/resources/swagger-ui-dist/absolute-path.js delete mode 100644 src/main/resources/swagger-ui-dist/favicon-16x16.png delete mode 100644 src/main/resources/swagger-ui-dist/favicon-32x32.png delete mode 100644 src/main/resources/swagger-ui-dist/index.html delete mode 100644 src/main/resources/swagger-ui-dist/index.js delete mode 100644 src/main/resources/swagger-ui-dist/oauth2-redirect.html delete mode 100644 src/main/resources/swagger-ui-dist/package.json delete mode 100644 src/main/resources/swagger-ui-dist/swagger-ui-bundle.js delete mode 100644 src/main/resources/swagger-ui-dist/swagger-ui-bundle.js.map delete mode 100644 src/main/resources/swagger-ui-dist/swagger-ui-standalone-preset.js delete mode 100644 src/main/resources/swagger-ui-dist/swagger-ui-standalone-preset.js.map delete mode 100644 src/main/resources/swagger-ui-dist/swagger-ui.css delete mode 100644 src/main/resources/swagger-ui-dist/swagger-ui.css.map delete mode 100644 src/main/resources/swagger-ui-dist/swagger-ui.js delete mode 100644 src/main/resources/swagger-ui-dist/swagger-ui.js.map delete mode 100644 src/main/resources/swagger-ui/.gitattributes delete mode 100644 src/main/resources/swagger-ui/css/print.css delete mode 100644 src/main/resources/swagger-ui/css/reset.css delete mode 100644 src/main/resources/swagger-ui/css/screen.css delete mode 100644 src/main/resources/swagger-ui/css/typography.css delete mode 100644 src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-700.eot delete mode 100644 src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-700.svg delete mode 100644 src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-700.ttf delete mode 100644 src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-700.woff delete mode 100644 src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-700.woff2 delete mode 100644 src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-regular.eot delete mode 100644 src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-regular.svg delete mode 100644 src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-regular.ttf delete mode 100644 src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-regular.woff delete mode 100644 src/main/resources/swagger-ui/fonts/droid-sans-v6-latin-regular.woff2 delete mode 100644 src/main/resources/swagger-ui/images/explorer_icons.png delete mode 100644 src/main/resources/swagger-ui/images/favicon-16x16.png delete mode 100644 src/main/resources/swagger-ui/images/favicon-32x32.png delete mode 100644 src/main/resources/swagger-ui/images/favicon.ico delete mode 100644 src/main/resources/swagger-ui/images/logo_small.png delete mode 100644 src/main/resources/swagger-ui/images/pet_store_api.png delete mode 100644 src/main/resources/swagger-ui/images/throbber.gif delete mode 100644 src/main/resources/swagger-ui/images/wordnik_api.png delete mode 100644 src/main/resources/swagger-ui/index.html delete mode 100644 src/main/resources/swagger-ui/lib/backbone-min.js delete mode 100644 src/main/resources/swagger-ui/lib/handlebars-2.0.0.js delete mode 100644 src/main/resources/swagger-ui/lib/highlight.7.3.pack.js delete mode 100644 src/main/resources/swagger-ui/lib/jquery-1.8.0.min.js delete mode 100644 src/main/resources/swagger-ui/lib/jquery.ba-bbq.min.js delete mode 100644 src/main/resources/swagger-ui/lib/jquery.slideto.min.js delete mode 100644 src/main/resources/swagger-ui/lib/jquery.wiggle.min.js delete mode 100644 src/main/resources/swagger-ui/lib/marked.js delete mode 100644 src/main/resources/swagger-ui/lib/swagger-oauth.js delete mode 100644 src/main/resources/swagger-ui/lib/underscore-min.js delete mode 100644 src/main/resources/swagger-ui/lib/underscore-min.map delete mode 100644 src/main/resources/swagger-ui/o2c.html delete mode 100644 src/main/resources/swagger-ui/swagger-ui.js delete mode 100644 src/main/resources/swagger-ui/swagger-ui.min.js diff --git a/core-init/src/main/resources/deployed-logback.xml b/core-init/src/main/resources/deployed-logback.xml new file mode 100644 index 0000000..b626b4b --- /dev/null +++ b/core-init/src/main/resources/deployed-logback.xml @@ -0,0 +1,86 @@ + + + ${HOSTNAME} + + + System.out + + DEBUG + ACCEPT + DENY + + + + + false + + api + yyyy-MM-dd'T'HH:mm:ss.SSS'Z' + UTC + true + + + + + System.out + + INFO + ACCEPT + DENY + + + + + false + + api + yyyy-MM-dd'T'HH:mm:ss.SSS'Z' + UTC + true + + + + + System.err + + WARN + ACCEPT + DENY + + + + + false + + api + yyyy-MM-dd'T'HH:mm:ss.SSS'Z' + UTC + true + + + + + System.err + + ERROR + ACCEPT + DENY + + + + + false + + api + yyyy-MM-dd'T'HH:mm:ss.SSS'Z' + UTC + true + + + + + + + + + diff --git a/core-init/src/main/resources/logback-test.xml b/core-init/src/main/resources/logback-test.xml new file mode 100644 index 0000000..d1a17ef --- /dev/null +++ b/core-init/src/main/resources/logback-test.xml @@ -0,0 +1,45 @@ + + + ${HOSTNAME} + + + System.out + + INFO + ACCEPT + DENY + + + [%d{yyyy-MM-dd HH:mm:ss.SSS}] %msg%n + + + + System.err + + WARN + ACCEPT + DENY + + + [%d{MM/dd/yyyy HH:mm:ss.SSS}] [%thread] %msg%n + + + + System.err + + ERROR + ACCEPT + DENY + + + [%d{MM/dd/yyyy HH:mm:ss.SSS}] [%thread] %msg%n + + + + + + + + + + diff --git a/core-init/src/main/resources/logback.xml b/core-init/src/main/resources/logback.xml new file mode 100644 index 0000000..97baf6d --- /dev/null +++ b/core-init/src/main/resources/logback.xml @@ -0,0 +1,69 @@ + + + ${HOSTNAME} + + + System.out + + SQL + ACCEPT + DENY + + + [%d{yyyy-MM-dd HH:mm:ss.SSS}] %msg%n + + + + System.out + + DEBUG + ACCEPT + DENY + + + [%d{yyyy-MM-dd HH:mm:ss.SSS}] %msg%n + + + + System.out + + INFO + ACCEPT + DENY + + + [%d{yyyy-MM-dd HH:mm:ss.SSS}] %msg%n + + + + System.err + + WARN + ACCEPT + DENY + + + [%d{MM/dd/yyyy HH:mm:ss.SSS}] [%thread] %msg%n + + + + System.err + + ERROR + ACCEPT + DENY + + + [%d{MM/dd/yyyy HH:mm:ss.SSS}] [%thread] %msg%n + + + + + + + + + + + + diff --git a/core-init/src/main/resources/reference.conf b/core-init/src/main/resources/reference.conf new file mode 100644 index 0000000..d5c7c4b --- /dev/null +++ b/core-init/src/main/resources/reference.conf @@ -0,0 +1,26 @@ +###################################################################### +# Default settings for driver core. Any settings defined by users of # +# this library will take precedence. See the documentation of the # +# Typesafe Config Library (https://github.com/lightbend/config) for # +# more information. # +###################################################################### + +# Kamon provides monitoring capabilities +kamon { + system-metrics { + # sigar reports host-specific metrics. Kubernetes takes care of + # that for Driver services. + host.enabled = false + + # JVM-related metrics + jmx.enabled = true + } + + statsd { + hostname = localhost + port = 8125 + simple-metric-key-generator { + include-hostname = false + } + } +} diff --git a/core-rest/src/main/resources/reference.conf b/core-rest/src/main/resources/reference.conf new file mode 100644 index 0000000..ea56cb5 --- /dev/null +++ b/core-rest/src/main/resources/reference.conf @@ -0,0 +1,54 @@ +# This scope is for general settings related to the execution of a +# specific service. +application { + baseUrl: "localhost:8080" + environment: "local_testing" + + cors.allowedOrigins: [ + "localhost", + "driver.xyz", + "driver.network", + "cndriver.xyz" + ] +} + +services.dev-overrides { + // {"service1": "http://localhost:8080"}, + // {"service2": "https://stable.sand.driver.network"} +} + +# Settings about the auto-generated REST API documentation. +swagger { + + # Version of the Swagger specification + # (https://swagger.io/specification/). Note that changing this will + # likely require changing the way Swagger is integrated into + # driver-core, involving upgrading libraries and web resources. + apiVersion = "2.0" + + basePath = "/" + + docsPath = "api-docs" + + # Description and usage of the specific API provided by the service + # using this library. + apiInfo { + + # Name of the service + title = "NEW SERVICE (change config swagger.apiInfo)" + + # Description of the service + description = "Please implement swagger info in your new service" + + # Contact for support about this service. + contact { + name = "Driver Inc." + url = "https://driver.xyz" + email = "info@driver.xyz" + } + + termsOfServiceUrl = "TOC Url" + license = "Apache V2" + licenseUrl = "http://www.apache.org/licenses/LICENSE-2.0" + } +} diff --git a/core-rest/src/main/resources/swagger-ui-dist/README.md b/core-rest/src/main/resources/swagger-ui-dist/README.md new file mode 100644 index 0000000..6628422 --- /dev/null +++ b/core-rest/src/main/resources/swagger-ui-dist/README.md @@ -0,0 +1,22 @@ +# Swagger UI Dist +[![NPM version](https://badge.fury.io/js/swagger-ui-dist.svg)](http://badge.fury.io/js/swagger-ui-dist) + +# API + +This module, `swagger-ui-dist`, exposes Swagger-UI's entire dist folder as a dependency-free npm module. +Use `swagger-ui` instead, if you'd like to have npm install dependencies for you. + +`SwaggerUIBundle` and `SwaggerUIStandalonePreset` can be imported: +```javascript + import { SwaggerUIBundle, SwaggerUIStandalonePreset } from "swagger-ui-dist" +``` + +To get an absolute path to this directory for static file serving, use the exported `getAbsoluteFSPath` method: + +```javascript +const swaggerUiAssetPath = require("swagger-ui-dist").getAbsoluteFSPath() + +// then instantiate server that serves files from the swaggerUiAssetPath +``` + +For anything else, check the [Swagger-UI](https://github.com/swagger-api/swagger-ui) repository. diff --git a/core-rest/src/main/resources/swagger-ui-dist/absolute-path.js b/core-rest/src/main/resources/swagger-ui-dist/absolute-path.js new file mode 100644 index 0000000..af42bc8 --- /dev/null +++ b/core-rest/src/main/resources/swagger-ui-dist/absolute-path.js @@ -0,0 +1,14 @@ +/* + * getAbsoluteFSPath + * @return {string} When run in NodeJS env, returns the absolute path to the current directory + * When run outside of NodeJS, will return an error message + */ +const getAbsoluteFSPath = function () { + // detect whether we are running in a browser or nodejs + if (typeof module !== "undefined" && module.exports) { + return require("path").resolve(__dirname) + } + throw new Error('getAbsoluteFSPath can only be called within a Nodejs environment'); +} + +module.exports = getAbsoluteFSPath diff --git a/core-rest/src/main/resources/swagger-ui-dist/favicon-16x16.png b/core-rest/src/main/resources/swagger-ui-dist/favicon-16x16.png new file mode 100644 index 0000000..0f7e13b Binary files /dev/null and b/core-rest/src/main/resources/swagger-ui-dist/favicon-16x16.png differ diff --git a/core-rest/src/main/resources/swagger-ui-dist/favicon-32x32.png b/core-rest/src/main/resources/swagger-ui-dist/favicon-32x32.png new file mode 100644 index 0000000..b0a3352 Binary files /dev/null and b/core-rest/src/main/resources/swagger-ui-dist/favicon-32x32.png differ diff --git a/core-rest/src/main/resources/swagger-ui-dist/index.html b/core-rest/src/main/resources/swagger-ui-dist/index.html new file mode 100644 index 0000000..b7385c0 --- /dev/null +++ b/core-rest/src/main/resources/swagger-ui-dist/index.html @@ -0,0 +1,60 @@ + + + + + + {{title}} + + + + + + + +
+ + + + + + diff --git a/core-rest/src/main/resources/swagger-ui-dist/index.js b/core-rest/src/main/resources/swagger-ui-dist/index.js new file mode 100644 index 0000000..c229ec4 --- /dev/null +++ b/core-rest/src/main/resources/swagger-ui-dist/index.js @@ -0,0 +1,17 @@ +try { + module.exports.SwaggerUIBundle = require("./swagger-ui-bundle.js") + module.exports.SwaggerUIStandalonePreset = require("./swagger-ui-standalone-preset.js") +} catch(e) { + // swallow the error if there's a problem loading the assets. + // allows this module to support providing the assets for browserish contexts, + // without exploding in a Node context. + // + // see https://github.com/swagger-api/swagger-ui/issues/3291#issuecomment-311195388 + // for more information. +} + +// `absolutePath` and `getAbsoluteFSPath` are both here because at one point, +// we documented having one and actually implemented the other. +// They were both retained so we don't break anyone's code. +module.exports.absolutePath = require("./absolute-path.js") +module.exports.getAbsoluteFSPath = require("./absolute-path.js") diff --git a/core-rest/src/main/resources/swagger-ui-dist/oauth2-redirect.html b/core-rest/src/main/resources/swagger-ui-dist/oauth2-redirect.html new file mode 100644 index 0000000..fb68399 --- /dev/null +++ b/core-rest/src/main/resources/swagger-ui-dist/oauth2-redirect.html @@ -0,0 +1,67 @@ + + + + + + diff --git a/core-rest/src/main/resources/swagger-ui-dist/package.json b/core-rest/src/main/resources/swagger-ui-dist/package.json new file mode 100644 index 0000000..1e74e17 --- /dev/null +++ b/core-rest/src/main/resources/swagger-ui-dist/package.json @@ -0,0 +1,72 @@ +{ + "_from": "swagger-ui-dist", + "_id": "swagger-ui-dist@3.18.2", + "_inBundle": false, + "_integrity": "sha512-pWAEiKkgWUJvjmLW9AojudnutJ+NTn5g6OdNLj1iIJWwCkoy40K3Upwa24DqFbmIE4vLX4XplND61hp2L+s5vg==", + "_location": "/swagger-ui-dist", + "_phantomChildren": {}, + "_requested": { + "type": "tag", + "registry": true, + "raw": "swagger-ui-dist", + "name": "swagger-ui-dist", + "escapedName": "swagger-ui-dist", + "rawSpec": "", + "saveSpec": null, + "fetchSpec": "latest" + }, + "_requiredBy": [ + "#USER", + "/" + ], + "_resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-3.18.2.tgz", + "_shasum": "025f8838a135f9476a839dd37696ba3ebd86ea06", + "_spec": "swagger-ui-dist", + "_where": "/home/jodersky/src/driver/driver-core", + "bugs": { + "url": "https://github.com/swagger-api/swagger-ui/issues" + }, + "bundleDependencies": false, + "contributors": [ + { + "url": "in alphabetical order" + }, + { + "name": "Anna Bodnia", + "email": "anna.bodnia@gmail.com" + }, + { + "name": "Buu Nguyen", + "email": "buunguyen@gmail.com" + }, + { + "name": "Josh Ponelat", + "email": "jponelat@gmail.com" + }, + { + "name": "Kyle Shockey", + "email": "kyleshockey1@gmail.com" + }, + { + "name": "Robert Barnwell", + "email": "robert@robertismy.name" + }, + { + "name": "Sahar Jafari", + "email": "shr.jafari@gmail.com" + } + ], + "dependencies": {}, + "deprecated": false, + "description": "[![NPM version](https://badge.fury.io/js/swagger-ui-dist.svg)](http://badge.fury.io/js/swagger-ui-dist)", + "devDependencies": {}, + "homepage": "https://github.com/swagger-api/swagger-ui#readme", + "license": "Apache-2.0", + "main": "index.js", + "name": "swagger-ui-dist", + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/swagger-api/swagger-ui.git" + }, + "version": "3.18.2" +} diff --git a/core-rest/src/main/resources/swagger-ui-dist/swagger-ui-bundle.js b/core-rest/src/main/resources/swagger-ui-dist/swagger-ui-bundle.js new file mode 100644 index 0000000..8c5384f --- /dev/null +++ b/core-rest/src/main/resources/swagger-ui-dist/swagger-ui-bundle.js @@ -0,0 +1,93 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.SwaggerUIBundle=t():e.SwaggerUIBundle=t()}("undefined"!=typeof self?self:this,function(){return function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{configurable:!1,enumerable:!0,get:r})},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist",n(n.s=445)}([function(e,t,n){"use strict";e.exports=n(75)},function(e,t,n){e.exports=n(855)()},function(e,t,n){"use strict";t.__esModule=!0,t.default=function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}},function(e,t,n){"use strict";t.__esModule=!0;var r,o=n(262),i=(r=o)&&r.__esModule?r:{default:r};t.default=function(){function e(e,t){for(var n=0;n>>0;if(""+n!==t||4294967295===n)return NaN;t=n}return t<0?C(e)+t:t}function A(){return!0}function O(e,t,n){return(0===e||void 0!==n&&e<=-n)&&(void 0===t||void 0!==n&&t>=n)}function P(e,t){return M(e,t,0)}function T(e,t){return M(e,t,t)}function M(e,t,n){return void 0===e?n:e<0?Math.max(0,t+e):void 0===t?e:Math.min(t,e)}var I=0,j=1,N=2,R="function"==typeof Symbol&&Symbol.iterator,D="@@iterator",L=R||D;function U(e){this.next=e}function q(e,t,n,r){var o=0===e?t:1===e?n:[t,n];return r?r.value=o:r={value:o,done:!1},r}function F(){return{value:void 0,done:!0}}function z(e){return!!H(e)}function B(e){return e&&"function"==typeof e.next}function V(e){var t=H(e);return t&&t.call(e)}function H(e){var t=e&&(R&&e[R]||e[D]);if("function"==typeof t)return t}function W(e){return e&&"number"==typeof e.length}function J(e){return null===e||void 0===e?ie():a(e)?e.toSeq():function(e){var t=se(e)||"object"==typeof e&&new te(e);if(!t)throw new TypeError("Expected Array or iterable object of values, or keyed object: "+e);return t}(e)}function Y(e){return null===e||void 0===e?ie().toKeyedSeq():a(e)?u(e)?e.toSeq():e.fromEntrySeq():ae(e)}function K(e){return null===e||void 0===e?ie():a(e)?u(e)?e.entrySeq():e.toIndexedSeq():ue(e)}function G(e){return(null===e||void 0===e?ie():a(e)?u(e)?e.entrySeq():e:ue(e)).toSetSeq()}U.prototype.toString=function(){return"[Iterator]"},U.KEYS=I,U.VALUES=j,U.ENTRIES=N,U.prototype.inspect=U.prototype.toSource=function(){return this.toString()},U.prototype[L]=function(){return this},t(J,n),J.of=function(){return J(arguments)},J.prototype.toSeq=function(){return this},J.prototype.toString=function(){return this.__toString("Seq {","}")},J.prototype.cacheResult=function(){return!this._cache&&this.__iterateUncached&&(this._cache=this.entrySeq().toArray(),this.size=this._cache.length),this},J.prototype.__iterate=function(e,t){return le(this,e,t,!0)},J.prototype.__iterator=function(e,t){return ce(this,e,t,!0)},t(Y,J),Y.prototype.toKeyedSeq=function(){return this},t(K,J),K.of=function(){return K(arguments)},K.prototype.toIndexedSeq=function(){return this},K.prototype.toString=function(){return this.__toString("Seq [","]")},K.prototype.__iterate=function(e,t){return le(this,e,t,!1)},K.prototype.__iterator=function(e,t){return ce(this,e,t,!1)},t(G,J),G.of=function(){return G(arguments)},G.prototype.toSetSeq=function(){return this},J.isSeq=oe,J.Keyed=Y,J.Set=G,J.Indexed=K;var $,Z,X,Q="@@__IMMUTABLE_SEQ__@@";function ee(e){this._array=e,this.size=e.length}function te(e){var t=Object.keys(e);this._object=e,this._keys=t,this.size=t.length}function ne(e){this._iterable=e,this.size=e.length||e.size}function re(e){this._iterator=e,this._iteratorCache=[]}function oe(e){return!(!e||!e[Q])}function ie(){return $||($=new ee([]))}function ae(e){var t=Array.isArray(e)?new ee(e).fromEntrySeq():B(e)?new re(e).fromEntrySeq():z(e)?new ne(e).fromEntrySeq():"object"==typeof e?new te(e):void 0;if(!t)throw new TypeError("Expected Array or iterable object of [k, v] entries, or keyed object: "+e);return t}function ue(e){var t=se(e);if(!t)throw new TypeError("Expected Array or iterable object of values: "+e);return t}function se(e){return W(e)?new ee(e):B(e)?new re(e):z(e)?new ne(e):void 0}function le(e,t,n,r){var o=e._cache;if(o){for(var i=o.length-1,a=0;a<=i;a++){var u=o[n?i-a:a];if(!1===t(u[1],r?u[0]:a,e))return a+1}return a}return e.__iterateUncached(t,n)}function ce(e,t,n,r){var o=e._cache;if(o){var i=o.length-1,a=0;return new U(function(){var e=o[n?i-a:a];return a++>i?{value:void 0,done:!0}:q(t,r?e[0]:a-1,e[1])})}return e.__iteratorUncached(t,n)}function fe(e,t){return t?function e(t,n,r,o){if(Array.isArray(n))return t.call(o,r,K(n).map(function(r,o){return e(t,r,o,n)}));if(de(n))return t.call(o,r,Y(n).map(function(r,o){return e(t,r,o,n)}));return n}(t,e,"",{"":e}):pe(e)}function pe(e){return Array.isArray(e)?K(e).map(pe).toList():de(e)?Y(e).map(pe).toMap():e}function de(e){return e&&(e.constructor===Object||void 0===e.constructor)}function he(e,t){if(e===t||e!=e&&t!=t)return!0;if(!e||!t)return!1;if("function"==typeof e.valueOf&&"function"==typeof t.valueOf){if((e=e.valueOf())===(t=t.valueOf())||e!=e&&t!=t)return!0;if(!e||!t)return!1}return!("function"!=typeof e.equals||"function"!=typeof t.equals||!e.equals(t))}function ve(e,t){if(e===t)return!0;if(!a(t)||void 0!==e.size&&void 0!==t.size&&e.size!==t.size||void 0!==e.__hash&&void 0!==t.__hash&&e.__hash!==t.__hash||u(e)!==u(t)||s(e)!==s(t)||c(e)!==c(t))return!1;if(0===e.size&&0===t.size)return!0;var n=!l(e);if(c(e)){var r=e.entries();return t.every(function(e,t){var o=r.next().value;return o&&he(o[1],e)&&(n||he(o[0],t))})&&r.next().done}var o=!1;if(void 0===e.size)if(void 0===t.size)"function"==typeof e.cacheResult&&e.cacheResult();else{o=!0;var i=e;e=t,t=i}var f=!0,p=t.__iterate(function(t,r){if(n?!e.has(t):o?!he(t,e.get(r,y)):!he(e.get(r,y),t))return f=!1,!1});return f&&e.size===p}function me(e,t){if(!(this instanceof me))return new me(e,t);if(this._value=e,this.size=void 0===t?1/0:Math.max(0,t),0===this.size){if(Z)return Z;Z=this}}function ge(e,t){if(!e)throw new Error(t)}function ye(e,t,n){if(!(this instanceof ye))return new ye(e,t,n);if(ge(0!==n,"Cannot step a Range by 0"),e=e||0,void 0===t&&(t=1/0),n=void 0===n?1:Math.abs(n),tr?{value:void 0,done:!0}:q(e,o,n[t?r-o++:o++])})},t(te,Y),te.prototype.get=function(e,t){return void 0===t||this.has(e)?this._object[e]:t},te.prototype.has=function(e){return this._object.hasOwnProperty(e)},te.prototype.__iterate=function(e,t){for(var n=this._object,r=this._keys,o=r.length-1,i=0;i<=o;i++){var a=r[t?o-i:i];if(!1===e(n[a],a,this))return i+1}return i},te.prototype.__iterator=function(e,t){var n=this._object,r=this._keys,o=r.length-1,i=0;return new U(function(){var a=r[t?o-i:i];return i++>o?{value:void 0,done:!0}:q(e,a,n[a])})},te.prototype[h]=!0,t(ne,K),ne.prototype.__iterateUncached=function(e,t){if(t)return this.cacheResult().__iterate(e,t);var n=V(this._iterable),r=0;if(B(n))for(var o;!(o=n.next()).done&&!1!==e(o.value,r++,this););return r},ne.prototype.__iteratorUncached=function(e,t){if(t)return this.cacheResult().__iterator(e,t);var n=V(this._iterable);if(!B(n))return new U(F);var r=0;return new U(function(){var t=n.next();return t.done?t:q(e,r++,t.value)})},t(re,K),re.prototype.__iterateUncached=function(e,t){if(t)return this.cacheResult().__iterate(e,t);for(var n,r=this._iterator,o=this._iteratorCache,i=0;i=r.length){var t=n.next();if(t.done)return t;r[o]=t.value}return q(e,o,r[o++])})},t(me,K),me.prototype.toString=function(){return 0===this.size?"Repeat []":"Repeat [ "+this._value+" "+this.size+" times ]"},me.prototype.get=function(e,t){return this.has(e)?this._value:t},me.prototype.includes=function(e){return he(this._value,e)},me.prototype.slice=function(e,t){var n=this.size;return O(e,t,n)?this:new me(this._value,T(t,n)-P(e,n))},me.prototype.reverse=function(){return this},me.prototype.indexOf=function(e){return he(this._value,e)?0:-1},me.prototype.lastIndexOf=function(e){return he(this._value,e)?this.size:-1},me.prototype.__iterate=function(e,t){for(var n=0;n=0&&t=0&&nn?{value:void 0,done:!0}:q(e,i++,a)})},ye.prototype.equals=function(e){return e instanceof ye?this._start===e._start&&this._end===e._end&&this._step===e._step:ve(this,e)},t(be,n),t(_e,be),t(we,be),t(Ee,be),be.Keyed=_e,be.Indexed=we,be.Set=Ee;var xe="function"==typeof Math.imul&&-2===Math.imul(4294967295,2)?Math.imul:function(e,t){var n=65535&(e|=0),r=65535&(t|=0);return n*r+((e>>>16)*r+n*(t>>>16)<<16>>>0)|0};function Se(e){return e>>>1&1073741824|3221225471&e}function Ce(e){if(!1===e||null===e||void 0===e)return 0;if("function"==typeof e.valueOf&&(!1===(e=e.valueOf())||null===e||void 0===e))return 0;if(!0===e)return 1;var t=typeof e;if("number"===t){if(e!=e||e===1/0)return 0;var n=0|e;for(n!==e&&(n^=4294967295*e);e>4294967295;)n^=e/=4294967295;return Se(n)}if("string"===t)return e.length>je?function(e){var t=De[e];void 0===t&&(t=ke(e),Re===Ne&&(Re=0,De={}),Re++,De[e]=t);return t}(e):ke(e);if("function"==typeof e.hashCode)return e.hashCode();if("object"===t)return function(e){var t;if(Te&&void 0!==(t=Pe.get(e)))return t;if(void 0!==(t=e[Ie]))return t;if(!Oe){if(void 0!==(t=e.propertyIsEnumerable&&e.propertyIsEnumerable[Ie]))return t;if(void 0!==(t=function(e){if(e&&e.nodeType>0)switch(e.nodeType){case 1:return e.uniqueID;case 9:return e.documentElement&&e.documentElement.uniqueID}}(e)))return t}t=++Me,1073741824&Me&&(Me=0);if(Te)Pe.set(e,t);else{if(void 0!==Ae&&!1===Ae(e))throw new Error("Non-extensible objects are not allowed as keys.");if(Oe)Object.defineProperty(e,Ie,{enumerable:!1,configurable:!1,writable:!1,value:t});else if(void 0!==e.propertyIsEnumerable&&e.propertyIsEnumerable===e.constructor.prototype.propertyIsEnumerable)e.propertyIsEnumerable=function(){return this.constructor.prototype.propertyIsEnumerable.apply(this,arguments)},e.propertyIsEnumerable[Ie]=t;else{if(void 0===e.nodeType)throw new Error("Unable to set a non-enumerable property on object.");e[Ie]=t}}return t}(e);if("function"==typeof e.toString)return ke(e.toString());throw new Error("Value type "+t+" cannot be hashed.")}function ke(e){for(var t=0,n=0;n=t.length)throw new Error("Missing value for key: "+t[n]);e.set(t[n],t[n+1])}})},Ue.prototype.toString=function(){return this.__toString("Map {","}")},Ue.prototype.get=function(e,t){return this._root?this._root.get(0,void 0,e,t):t},Ue.prototype.set=function(e,t){return Qe(this,e,t)},Ue.prototype.setIn=function(e,t){return this.updateIn(e,y,function(){return t})},Ue.prototype.remove=function(e){return Qe(this,e,y)},Ue.prototype.deleteIn=function(e){return this.updateIn(e,function(){return y})},Ue.prototype.update=function(e,t,n){return 1===arguments.length?e(this):this.updateIn([e],t,n)},Ue.prototype.updateIn=function(e,t,n){n||(n=t,t=void 0);var r=function e(t,n,r,o){var i=t===y;var a=n.next();if(a.done){var u=i?r:t,s=o(u);return s===u?t:s}ge(i||t&&t.set,"invalid keyPath");var l=a.value;var c=i?y:t.get(l,y);var f=e(c,n,r,o);return f===c?t:f===y?t.remove(l):(i?Xe():t).set(l,f)}(this,nn(e),t,n);return r===y?void 0:r},Ue.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._root=null,this.__hash=void 0,this.__altered=!0,this):Xe()},Ue.prototype.merge=function(){return rt(this,void 0,arguments)},Ue.prototype.mergeWith=function(t){return rt(this,t,e.call(arguments,1))},Ue.prototype.mergeIn=function(t){var n=e.call(arguments,1);return this.updateIn(t,Xe(),function(e){return"function"==typeof e.merge?e.merge.apply(e,n):n[n.length-1]})},Ue.prototype.mergeDeep=function(){return rt(this,ot,arguments)},Ue.prototype.mergeDeepWith=function(t){var n=e.call(arguments,1);return rt(this,it(t),n)},Ue.prototype.mergeDeepIn=function(t){var n=e.call(arguments,1);return this.updateIn(t,Xe(),function(e){return"function"==typeof e.mergeDeep?e.mergeDeep.apply(e,n):n[n.length-1]})},Ue.prototype.sort=function(e){return Pt(Wt(this,e))},Ue.prototype.sortBy=function(e,t){return Pt(Wt(this,t,e))},Ue.prototype.withMutations=function(e){var t=this.asMutable();return e(t),t.wasAltered()?t.__ensureOwner(this.__ownerID):this},Ue.prototype.asMutable=function(){return this.__ownerID?this:this.__ensureOwner(new x)},Ue.prototype.asImmutable=function(){return this.__ensureOwner()},Ue.prototype.wasAltered=function(){return this.__altered},Ue.prototype.__iterator=function(e,t){return new Ke(this,e,t)},Ue.prototype.__iterate=function(e,t){var n=this,r=0;return this._root&&this._root.iterate(function(t){return r++,e(t[1],t[0],n)},t),r},Ue.prototype.__ensureOwner=function(e){return e===this.__ownerID?this:e?Ze(this.size,this._root,e,this.__hash):(this.__ownerID=e,this.__altered=!1,this)},Ue.isMap=qe;var Fe,ze="@@__IMMUTABLE_MAP__@@",Be=Ue.prototype;function Ve(e,t){this.ownerID=e,this.entries=t}function He(e,t,n){this.ownerID=e,this.bitmap=t,this.nodes=n}function We(e,t,n){this.ownerID=e,this.count=t,this.nodes=n}function Je(e,t,n){this.ownerID=e,this.keyHash=t,this.entries=n}function Ye(e,t,n){this.ownerID=e,this.keyHash=t,this.entry=n}function Ke(e,t,n){this._type=t,this._reverse=n,this._stack=e._root&&$e(e._root)}function Ge(e,t){return q(e,t[0],t[1])}function $e(e,t){return{node:e,index:0,__prev:t}}function Ze(e,t,n,r){var o=Object.create(Be);return o.size=e,o._root=t,o.__ownerID=n,o.__hash=r,o.__altered=!1,o}function Xe(){return Fe||(Fe=Ze(0))}function Qe(e,t,n){var r,o;if(e._root){var i=w(b),a=w(_);if(r=et(e._root,e.__ownerID,0,void 0,t,n,i,a),!a.value)return e;o=e.size+(i.value?n===y?-1:1:0)}else{if(n===y)return e;o=1,r=new Ve(e.__ownerID,[[t,n]])}return e.__ownerID?(e.size=o,e._root=r,e.__hash=void 0,e.__altered=!0,e):r?Ze(o,r):Xe()}function et(e,t,n,r,o,i,a,u){return e?e.update(t,n,r,o,i,a,u):i===y?e:(E(u),E(a),new Ye(t,r,[o,i]))}function tt(e){return e.constructor===Ye||e.constructor===Je}function nt(e,t,n,r,o){if(e.keyHash===r)return new Je(t,r,[e.entry,o]);var i,a=(0===n?e.keyHash:e.keyHash>>>n)&g,u=(0===n?r:r>>>n)&g;return new He(t,1<>1&1431655765))+(e>>2&858993459))+(e>>4)&252645135,e+=e>>8,127&(e+=e>>16)}function st(e,t,n,r){var o=r?e:S(e);return o[t]=n,o}Be[ze]=!0,Be.delete=Be.remove,Be.removeIn=Be.deleteIn,Ve.prototype.get=function(e,t,n,r){for(var o=this.entries,i=0,a=o.length;i=lt)return function(e,t,n,r){e||(e=new x);for(var o=new Ye(e,Ce(n),[n,r]),i=0;i>>e)&g),i=this.bitmap;return 0==(i&o)?r:this.nodes[ut(i&o-1)].get(e+v,t,n,r)},He.prototype.update=function(e,t,n,r,o,i,a){void 0===n&&(n=Ce(r));var u=(0===t?n:n>>>t)&g,s=1<=ct)return function(e,t,n,r,o){for(var i=0,a=new Array(m),u=0;0!==n;u++,n>>>=1)a[u]=1&n?t[i++]:void 0;return a[r]=o,new We(e,i+1,a)}(e,p,l,u,h);if(c&&!h&&2===p.length&&tt(p[1^f]))return p[1^f];if(c&&h&&1===p.length&&tt(h))return h;var b=e&&e===this.ownerID,_=c?h?l:l^s:l|s,w=c?h?st(p,f,h,b):function(e,t,n){var r=e.length-1;if(n&&t===r)return e.pop(),e;for(var o=new Array(r),i=0,a=0;a>>e)&g,i=this.nodes[o];return i?i.get(e+v,t,n,r):r},We.prototype.update=function(e,t,n,r,o,i,a){void 0===n&&(n=Ce(r));var u=(0===t?n:n>>>t)&g,s=o===y,l=this.nodes,c=l[u];if(s&&!c)return this;var f=et(c,e,t+v,n,r,o,i,a);if(f===c)return this;var p=this.count;if(c){if(!f&&--p0&&r=0&&e=e.size||t<0)return e.withMutations(function(e){t<0?kt(e,t).set(0,n):kt(e,0,t+1).set(t,n)});t+=e._origin;var r=e._tail,o=e._root,i=w(_);t>=Ot(e._capacity)?r=xt(r,e.__ownerID,0,t,n,i):o=xt(o,e.__ownerID,e._level,t,n,i);if(!i.value)return e;if(e.__ownerID)return e._root=o,e._tail=r,e.__hash=void 0,e.__altered=!0,e;return wt(e._origin,e._capacity,e._level,o,r)}(this,e,t)},pt.prototype.remove=function(e){return this.has(e)?0===e?this.shift():e===this.size-1?this.pop():this.splice(e,1):this},pt.prototype.insert=function(e,t){return this.splice(e,0,t)},pt.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=this._origin=this._capacity=0,this._level=v,this._root=this._tail=null,this.__hash=void 0,this.__altered=!0,this):Et()},pt.prototype.push=function(){var e=arguments,t=this.size;return this.withMutations(function(n){kt(n,0,t+e.length);for(var r=0;r>>t&g;if(r>=this.array.length)return new mt([],e);var o,i=0===r;if(t>0){var a=this.array[r];if((o=a&&a.removeBefore(e,t-v,n))===a&&i)return this}if(i&&!o)return this;var u=St(this,e);if(!i)for(var s=0;s>>t&g;if(o>=this.array.length)return this;if(t>0){var i=this.array[o];if((r=i&&i.removeAfter(e,t-v,n))===i&&o===this.array.length-1)return this}var a=St(this,e);return a.array.splice(o+1),r&&(a.array[o]=r),a};var gt,yt,bt={};function _t(e,t){var n=e._origin,r=e._capacity,o=Ot(r),i=e._tail;return a(e._root,e._level,0);function a(e,u,s){return 0===u?function(e,a){var u=a===o?i&&i.array:e&&e.array,s=a>n?0:n-a,l=r-a;l>m&&(l=m);return function(){if(s===l)return bt;var e=t?--l:s++;return u&&u[e]}}(e,s):function(e,o,i){var u,s=e&&e.array,l=i>n?0:n-i>>o,c=1+(r-i>>o);c>m&&(c=m);return function(){for(;;){if(u){var e=u();if(e!==bt)return e;u=null}if(l===c)return bt;var n=t?--c:l++;u=a(s&&s[n],o-v,i+(n<>>n&g,s=e&&u0){var l=e&&e.array[u],c=xt(l,t,n-v,r,o,i);return c===l?e:((a=St(e,t)).array[u]=c,a)}return s&&e.array[u]===o?e:(E(i),a=St(e,t),void 0===o&&u===a.array.length-1?a.array.pop():a.array[u]=o,a)}function St(e,t){return t&&e&&t===e.ownerID?e:new mt(e?e.array.slice():[],t)}function Ct(e,t){if(t>=Ot(e._capacity))return e._tail;if(t<1<0;)n=n.array[t>>>r&g],r-=v;return n}}function kt(e,t,n){void 0!==t&&(t|=0),void 0!==n&&(n|=0);var r=e.__ownerID||new x,o=e._origin,i=e._capacity,a=o+t,u=void 0===n?i:n<0?i+n:o+n;if(a===o&&u===i)return e;if(a>=u)return e.clear();for(var s=e._level,l=e._root,c=0;a+c<0;)l=new mt(l&&l.array.length?[void 0,l]:[],r),c+=1<<(s+=v);c&&(a+=c,o+=c,u+=c,i+=c);for(var f=Ot(i),p=Ot(u);p>=1<f?new mt([],r):d;if(d&&p>f&&av;y-=v){var b=f>>>y&g;m=m.array[b]=St(m.array[b],r)}m.array[f>>>v&g]=d}if(u=p)a-=p,u-=p,s=v,l=null,h=h&&h.removeBefore(r,0,a);else if(a>o||p>>s&g;if(_!==p>>>s&g)break;_&&(c+=(1<o&&(l=l.removeBefore(r,s,a-c)),l&&pi&&(i=l.size),a(s)||(l=l.map(function(e){return fe(e)})),r.push(l)}return i>e.size&&(e=e.setSize(i)),at(e,t,r)}function Ot(e){return e>>v<=m&&a.size>=2*i.size?(r=(o=a.filter(function(e,t){return void 0!==e&&u!==t})).toKeyedSeq().map(function(e){return e[0]}).flip().toMap(),e.__ownerID&&(r.__ownerID=o.__ownerID=e.__ownerID)):(r=i.remove(t),o=u===a.size-1?a.pop():a.set(u,void 0))}else if(s){if(n===a.get(u)[1])return e;r=i,o=a.set(u,[t,n])}else r=i.set(t,a.size),o=a.set(a.size,[t,n]);return e.__ownerID?(e.size=r.size,e._map=r,e._list=o,e.__hash=void 0,e):Mt(r,o)}function Nt(e,t){this._iter=e,this._useKeys=t,this.size=e.size}function Rt(e){this._iter=e,this.size=e.size}function Dt(e){this._iter=e,this.size=e.size}function Lt(e){this._iter=e,this.size=e.size}function Ut(e){var t=Qt(e);return t._iter=e,t.size=e.size,t.flip=function(){return e},t.reverse=function(){var t=e.reverse.apply(this);return t.flip=function(){return e.reverse()},t},t.has=function(t){return e.includes(t)},t.includes=function(t){return e.has(t)},t.cacheResult=en,t.__iterateUncached=function(t,n){var r=this;return e.__iterate(function(e,n){return!1!==t(n,e,r)},n)},t.__iteratorUncached=function(t,n){if(t===N){var r=e.__iterator(t,n);return new U(function(){var e=r.next();if(!e.done){var t=e.value[0];e.value[0]=e.value[1],e.value[1]=t}return e})}return e.__iterator(t===j?I:j,n)},t}function qt(e,t,n){var r=Qt(e);return r.size=e.size,r.has=function(t){return e.has(t)},r.get=function(r,o){var i=e.get(r,y);return i===y?o:t.call(n,i,r,e)},r.__iterateUncached=function(r,o){var i=this;return e.__iterate(function(e,o,a){return!1!==r(t.call(n,e,o,a),o,i)},o)},r.__iteratorUncached=function(r,o){var i=e.__iterator(N,o);return new U(function(){var o=i.next();if(o.done)return o;var a=o.value,u=a[0];return q(r,u,t.call(n,a[1],u,e),o)})},r}function Ft(e,t){var n=Qt(e);return n._iter=e,n.size=e.size,n.reverse=function(){return e},e.flip&&(n.flip=function(){var t=Ut(e);return t.reverse=function(){return e.flip()},t}),n.get=function(n,r){return e.get(t?n:-1-n,r)},n.has=function(n){return e.has(t?n:-1-n)},n.includes=function(t){return e.includes(t)},n.cacheResult=en,n.__iterate=function(t,n){var r=this;return e.__iterate(function(e,n){return t(e,n,r)},!n)},n.__iterator=function(t,n){return e.__iterator(t,!n)},n}function zt(e,t,n,r){var o=Qt(e);return r&&(o.has=function(r){var o=e.get(r,y);return o!==y&&!!t.call(n,o,r,e)},o.get=function(r,o){var i=e.get(r,y);return i!==y&&t.call(n,i,r,e)?i:o}),o.__iterateUncached=function(o,i){var a=this,u=0;return e.__iterate(function(e,i,s){if(t.call(n,e,i,s))return u++,o(e,r?i:u-1,a)},i),u},o.__iteratorUncached=function(o,i){var a=e.__iterator(N,i),u=0;return new U(function(){for(;;){var i=a.next();if(i.done)return i;var s=i.value,l=s[0],c=s[1];if(t.call(n,c,l,e))return q(o,r?l:u++,c,i)}})},o}function Bt(e,t,n,r){var o=e.size;if(void 0!==t&&(t|=0),void 0!==n&&(n===1/0?n=o:n|=0),O(t,n,o))return e;var i=P(t,o),a=T(n,o);if(i!=i||a!=a)return Bt(e.toSeq().cacheResult(),t,n,r);var u,s=a-i;s==s&&(u=s<0?0:s);var l=Qt(e);return l.size=0===u?u:e.size&&u||void 0,!r&&oe(e)&&u>=0&&(l.get=function(t,n){return(t=k(this,t))>=0&&tu)return{value:void 0,done:!0};var e=o.next();return r||t===j?e:q(t,s-1,t===I?void 0:e.value[1],e)})},l}function Vt(e,t,n,r){var o=Qt(e);return o.__iterateUncached=function(o,i){var a=this;if(i)return this.cacheResult().__iterate(o,i);var u=!0,s=0;return e.__iterate(function(e,i,l){if(!u||!(u=t.call(n,e,i,l)))return s++,o(e,r?i:s-1,a)}),s},o.__iteratorUncached=function(o,i){var a=this;if(i)return this.cacheResult().__iterator(o,i);var u=e.__iterator(N,i),s=!0,l=0;return new U(function(){var e,i,c;do{if((e=u.next()).done)return r||o===j?e:q(o,l++,o===I?void 0:e.value[1],e);var f=e.value;i=f[0],c=f[1],s&&(s=t.call(n,c,i,a))}while(s);return o===N?e:q(o,i,c,e)})},o}function Ht(e,t,n){var r=Qt(e);return r.__iterateUncached=function(r,o){var i=0,u=!1;return function e(s,l){var c=this;s.__iterate(function(o,s){return(!t||l0}function Kt(e,t,r){var o=Qt(e);return o.size=new ee(r).map(function(e){return e.size}).min(),o.__iterate=function(e,t){for(var n,r=this.__iterator(j,t),o=0;!(n=r.next()).done&&!1!==e(n.value,o++,this););return o},o.__iteratorUncached=function(e,o){var i=r.map(function(e){return e=n(e),V(o?e.reverse():e)}),a=0,u=!1;return new U(function(){var n;return u||(n=i.map(function(e){return e.next()}),u=n.some(function(e){return e.done})),u?{value:void 0,done:!0}:q(e,a++,t.apply(null,n.map(function(e){return e.value})))})},o}function Gt(e,t){return oe(e)?t:e.constructor(t)}function $t(e){if(e!==Object(e))throw new TypeError("Expected [K, V] tuple: "+e)}function Zt(e){return Le(e.size),C(e)}function Xt(e){return u(e)?r:s(e)?o:i}function Qt(e){return Object.create((u(e)?Y:s(e)?K:G).prototype)}function en(){return this._iter.cacheResult?(this._iter.cacheResult(),this.size=this._iter.size,this):J.prototype.cacheResult.call(this)}function tn(e,t){return e>t?1:e=0;n--)t={value:arguments[n],next:t};return this.__ownerID?(this.size=e,this._head=t,this.__hash=void 0,this.__altered=!0,this):An(e,t)},En.prototype.pushAll=function(e){if(0===(e=o(e)).size)return this;Le(e.size);var t=this.size,n=this._head;return e.reverse().forEach(function(e){t++,n={value:e,next:n}}),this.__ownerID?(this.size=t,this._head=n,this.__hash=void 0,this.__altered=!0,this):An(t,n)},En.prototype.pop=function(){return this.slice(1)},En.prototype.unshift=function(){return this.push.apply(this,arguments)},En.prototype.unshiftAll=function(e){return this.pushAll(e)},En.prototype.shift=function(){return this.pop.apply(this,arguments)},En.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._head=void 0,this.__hash=void 0,this.__altered=!0,this):On()},En.prototype.slice=function(e,t){if(O(e,t,this.size))return this;var n=P(e,this.size);if(T(t,this.size)!==this.size)return we.prototype.slice.call(this,e,t);for(var r=this.size-n,o=this._head;n--;)o=o.next;return this.__ownerID?(this.size=r,this._head=o,this.__hash=void 0,this.__altered=!0,this):An(r,o)},En.prototype.__ensureOwner=function(e){return e===this.__ownerID?this:e?An(this.size,this._head,e,this.__hash):(this.__ownerID=e,this.__altered=!1,this)},En.prototype.__iterate=function(e,t){if(t)return this.reverse().__iterate(e);for(var n=0,r=this._head;r&&!1!==e(r.value,n++,this);)r=r.next;return n},En.prototype.__iterator=function(e,t){if(t)return this.reverse().__iterator(e);var n=0,r=this._head;return new U(function(){if(r){var t=r.value;return r=r.next,q(e,n++,t)}return{value:void 0,done:!0}})},En.isStack=xn;var Sn,Cn="@@__IMMUTABLE_STACK__@@",kn=En.prototype;function An(e,t,n,r){var o=Object.create(kn);return o.size=e,o._head=t,o.__ownerID=n,o.__hash=r,o.__altered=!1,o}function On(){return Sn||(Sn=An(0))}function Pn(e,t){var n=function(n){e.prototype[n]=t[n]};return Object.keys(t).forEach(n),Object.getOwnPropertySymbols&&Object.getOwnPropertySymbols(t).forEach(n),e}kn[Cn]=!0,kn.withMutations=Be.withMutations,kn.asMutable=Be.asMutable,kn.asImmutable=Be.asImmutable,kn.wasAltered=Be.wasAltered,n.Iterator=U,Pn(n,{toArray:function(){Le(this.size);var e=new Array(this.size||0);return this.valueSeq().__iterate(function(t,n){e[n]=t}),e},toIndexedSeq:function(){return new Rt(this)},toJS:function(){return this.toSeq().map(function(e){return e&&"function"==typeof e.toJS?e.toJS():e}).__toJS()},toJSON:function(){return this.toSeq().map(function(e){return e&&"function"==typeof e.toJSON?e.toJSON():e}).__toJS()},toKeyedSeq:function(){return new Nt(this,!0)},toMap:function(){return Ue(this.toKeyedSeq())},toObject:function(){Le(this.size);var e={};return this.__iterate(function(t,n){e[n]=t}),e},toOrderedMap:function(){return Pt(this.toKeyedSeq())},toOrderedSet:function(){return mn(u(this)?this.valueSeq():this)},toSet:function(){return sn(u(this)?this.valueSeq():this)},toSetSeq:function(){return new Dt(this)},toSeq:function(){return s(this)?this.toIndexedSeq():u(this)?this.toKeyedSeq():this.toSetSeq()},toStack:function(){return En(u(this)?this.valueSeq():this)},toList:function(){return pt(u(this)?this.valueSeq():this)},toString:function(){return"[Iterable]"},__toString:function(e,t){return 0===this.size?e+t:e+" "+this.toSeq().map(this.__toStringMapper).join(", ")+" "+t},concat:function(){return Gt(this,function(e,t){var n=u(e),o=[e].concat(t).map(function(e){return a(e)?n&&(e=r(e)):e=n?ae(e):ue(Array.isArray(e)?e:[e]),e}).filter(function(e){return 0!==e.size});if(0===o.length)return e;if(1===o.length){var i=o[0];if(i===e||n&&u(i)||s(e)&&s(i))return i}var l=new ee(o);return n?l=l.toKeyedSeq():s(e)||(l=l.toSetSeq()),(l=l.flatten(!0)).size=o.reduce(function(e,t){if(void 0!==e){var n=t.size;if(void 0!==n)return e+n}},0),l}(this,e.call(arguments,0)))},includes:function(e){return this.some(function(t){return he(t,e)})},entries:function(){return this.__iterator(N)},every:function(e,t){Le(this.size);var n=!0;return this.__iterate(function(r,o,i){if(!e.call(t,r,o,i))return n=!1,!1}),n},filter:function(e,t){return Gt(this,zt(this,e,t,!0))},find:function(e,t,n){var r=this.findEntry(e,t);return r?r[1]:n},forEach:function(e,t){return Le(this.size),this.__iterate(t?e.bind(t):e)},join:function(e){Le(this.size),e=void 0!==e?""+e:",";var t="",n=!0;return this.__iterate(function(r){n?n=!1:t+=e,t+=null!==r&&void 0!==r?r.toString():""}),t},keys:function(){return this.__iterator(I)},map:function(e,t){return Gt(this,qt(this,e,t))},reduce:function(e,t,n){var r,o;return Le(this.size),arguments.length<2?o=!0:r=t,this.__iterate(function(t,i,a){o?(o=!1,r=t):r=e.call(n,r,t,i,a)}),r},reduceRight:function(e,t,n){var r=this.toKeyedSeq().reverse();return r.reduce.apply(r,arguments)},reverse:function(){return Gt(this,Ft(this,!0))},slice:function(e,t){return Gt(this,Bt(this,e,t,!0))},some:function(e,t){return!this.every(Nn(e),t)},sort:function(e){return Gt(this,Wt(this,e))},values:function(){return this.__iterator(j)},butLast:function(){return this.slice(0,-1)},isEmpty:function(){return void 0!==this.size?0===this.size:!this.some(function(){return!0})},count:function(e,t){return C(e?this.toSeq().filter(e,t):this)},countBy:function(e,t){return function(e,t,n){var r=Ue().asMutable();return e.__iterate(function(o,i){r.update(t.call(n,o,i,e),0,function(e){return e+1})}),r.asImmutable()}(this,e,t)},equals:function(e){return ve(this,e)},entrySeq:function(){var e=this;if(e._cache)return new ee(e._cache);var t=e.toSeq().map(jn).toIndexedSeq();return t.fromEntrySeq=function(){return e.toSeq()},t},filterNot:function(e,t){return this.filter(Nn(e),t)},findEntry:function(e,t,n){var r=n;return this.__iterate(function(n,o,i){if(e.call(t,n,o,i))return r=[o,n],!1}),r},findKey:function(e,t){var n=this.findEntry(e,t);return n&&n[0]},findLast:function(e,t,n){return this.toKeyedSeq().reverse().find(e,t,n)},findLastEntry:function(e,t,n){return this.toKeyedSeq().reverse().findEntry(e,t,n)},findLastKey:function(e,t){return this.toKeyedSeq().reverse().findKey(e,t)},first:function(){return this.find(A)},flatMap:function(e,t){return Gt(this,function(e,t,n){var r=Xt(e);return e.toSeq().map(function(o,i){return r(t.call(n,o,i,e))}).flatten(!0)}(this,e,t))},flatten:function(e){return Gt(this,Ht(this,e,!0))},fromEntrySeq:function(){return new Lt(this)},get:function(e,t){return this.find(function(t,n){return he(n,e)},void 0,t)},getIn:function(e,t){for(var n,r=this,o=nn(e);!(n=o.next()).done;){var i=n.value;if((r=r&&r.get?r.get(i,y):y)===y)return t}return r},groupBy:function(e,t){return function(e,t,n){var r=u(e),o=(c(e)?Pt():Ue()).asMutable();e.__iterate(function(i,a){o.update(t.call(n,i,a,e),function(e){return(e=e||[]).push(r?[a,i]:i),e})});var i=Xt(e);return o.map(function(t){return Gt(e,i(t))})}(this,e,t)},has:function(e){return this.get(e,y)!==y},hasIn:function(e){return this.getIn(e,y)!==y},isSubset:function(e){return e="function"==typeof e.includes?e:n(e),this.every(function(t){return e.includes(t)})},isSuperset:function(e){return(e="function"==typeof e.isSubset?e:n(e)).isSubset(this)},keyOf:function(e){return this.findKey(function(t){return he(t,e)})},keySeq:function(){return this.toSeq().map(In).toIndexedSeq()},last:function(){return this.toSeq().reverse().first()},lastKeyOf:function(e){return this.toKeyedSeq().reverse().keyOf(e)},max:function(e){return Jt(this,e)},maxBy:function(e,t){return Jt(this,t,e)},min:function(e){return Jt(this,e?Rn(e):Un)},minBy:function(e,t){return Jt(this,t?Rn(t):Un,e)},rest:function(){return this.slice(1)},skip:function(e){return this.slice(Math.max(0,e))},skipLast:function(e){return Gt(this,this.toSeq().reverse().skip(e).reverse())},skipWhile:function(e,t){return Gt(this,Vt(this,e,t,!0))},skipUntil:function(e,t){return this.skipWhile(Nn(e),t)},sortBy:function(e,t){return Gt(this,Wt(this,t,e))},take:function(e){return this.slice(0,Math.max(0,e))},takeLast:function(e){return Gt(this,this.toSeq().reverse().take(e).reverse())},takeWhile:function(e,t){return Gt(this,function(e,t,n){var r=Qt(e);return r.__iterateUncached=function(r,o){var i=this;if(o)return this.cacheResult().__iterate(r,o);var a=0;return e.__iterate(function(e,o,u){return t.call(n,e,o,u)&&++a&&r(e,o,i)}),a},r.__iteratorUncached=function(r,o){var i=this;if(o)return this.cacheResult().__iterator(r,o);var a=e.__iterator(N,o),u=!0;return new U(function(){if(!u)return{value:void 0,done:!0};var e=a.next();if(e.done)return e;var o=e.value,s=o[0],l=o[1];return t.call(n,l,s,i)?r===N?e:q(r,s,l,e):(u=!1,{value:void 0,done:!0})})},r}(this,e,t))},takeUntil:function(e,t){return this.takeWhile(Nn(e),t)},valueSeq:function(){return this.toIndexedSeq()},hashCode:function(){return this.__hash||(this.__hash=function(e){if(e.size===1/0)return 0;var t=c(e),n=u(e),r=t?1:0;return function(e,t){return t=xe(t,3432918353),t=xe(t<<15|t>>>-15,461845907),t=xe(t<<13|t>>>-13,5),t=xe((t=(t+3864292196|0)^e)^t>>>16,2246822507),t=Se((t=xe(t^t>>>13,3266489909))^t>>>16)}(e.__iterate(n?t?function(e,t){r=31*r+qn(Ce(e),Ce(t))|0}:function(e,t){r=r+qn(Ce(e),Ce(t))|0}:t?function(e){r=31*r+Ce(e)|0}:function(e){r=r+Ce(e)|0}),r)}(this))}});var Tn=n.prototype;Tn[f]=!0,Tn[L]=Tn.values,Tn.__toJS=Tn.toArray,Tn.__toStringMapper=Dn,Tn.inspect=Tn.toSource=function(){return this.toString()},Tn.chain=Tn.flatMap,Tn.contains=Tn.includes,Pn(r,{flip:function(){return Gt(this,Ut(this))},mapEntries:function(e,t){var n=this,r=0;return Gt(this,this.toSeq().map(function(o,i){return e.call(t,[i,o],r++,n)}).fromEntrySeq())},mapKeys:function(e,t){var n=this;return Gt(this,this.toSeq().flip().map(function(r,o){return e.call(t,r,o,n)}).flip())}});var Mn=r.prototype;function In(e,t){return t}function jn(e,t){return[t,e]}function Nn(e){return function(){return!e.apply(this,arguments)}}function Rn(e){return function(){return-e.apply(this,arguments)}}function Dn(e){return"string"==typeof e?JSON.stringify(e):String(e)}function Ln(){return S(arguments)}function Un(e,t){return et?-1:0}function qn(e,t){return e^t+2654435769+(e<<6)+(e>>2)|0}return Mn[p]=!0,Mn[L]=Tn.entries,Mn.__toJS=Tn.toObject,Mn.__toStringMapper=function(e,t){return JSON.stringify(t)+": "+Dn(e)},Pn(o,{toKeyedSeq:function(){return new Nt(this,!1)},filter:function(e,t){return Gt(this,zt(this,e,t,!1))},findIndex:function(e,t){var n=this.findEntry(e,t);return n?n[0]:-1},indexOf:function(e){var t=this.keyOf(e);return void 0===t?-1:t},lastIndexOf:function(e){var t=this.lastKeyOf(e);return void 0===t?-1:t},reverse:function(){return Gt(this,Ft(this,!1))},slice:function(e,t){return Gt(this,Bt(this,e,t,!1))},splice:function(e,t){var n=arguments.length;if(t=Math.max(0|t,0),0===n||2===n&&!t)return this;e=P(e,e<0?this.count():this.size);var r=this.slice(0,e);return Gt(this,1===n?r:r.concat(S(arguments,2),this.slice(e+t)))},findLastIndex:function(e,t){var n=this.findLastEntry(e,t);return n?n[0]:-1},first:function(){return this.get(0)},flatten:function(e){return Gt(this,Ht(this,e,!1))},get:function(e,t){return(e=k(this,e))<0||this.size===1/0||void 0!==this.size&&e>this.size?t:this.find(function(t,n){return n===e},void 0,t)},has:function(e){return(e=k(this,e))>=0&&(void 0!==this.size?this.size===1/0||e5e3)return e.textContent;return function(e){for(var n,r,o,i,a,u=e.textContent,s=0,l=u[0],c=1,f=e.innerHTML="",p=0;r=n,n=p<7&&"\\"==n?1:c;){if(c=l,l=u[++s],i=f.length>1,!c||p>8&&"\n"==c||[/\S/.test(c),1,1,!/[$\w]/.test(c),("/"==n||"\n"==n)&&i,'"'==n&&i,"'"==n&&i,u[s-4]+r+n=="--\x3e",r+n=="*/"][p])for(f&&(e.appendChild(a=t.createElement("span")).setAttribute("style",["color: #555; font-weight: bold;","","","color: #555;",""][p?p<3?2:p>6?4:p>3?3:+/^(a(bstract|lias|nd|rguments|rray|s(m|sert)?|uto)|b(ase|egin|ool(ean)?|reak|yte)|c(ase|atch|har|hecked|lass|lone|ompl|onst|ontinue)|de(bugger|cimal|clare|f(ault|er)?|init|l(egate|ete)?)|do|double|e(cho|ls?if|lse(if)?|nd|nsure|num|vent|x(cept|ec|p(licit|ort)|te(nds|nsion|rn)))|f(allthrough|alse|inal(ly)?|ixed|loat|or(each)?|riend|rom|unc(tion)?)|global|goto|guard|i(f|mp(lements|licit|ort)|n(it|clude(_once)?|line|out|stanceof|t(erface|ernal)?)?|s)|l(ambda|et|ock|ong)|m(icrolight|odule|utable)|NaN|n(amespace|ative|ext|ew|il|ot|ull)|o(bject|perator|r|ut|verride)|p(ackage|arams|rivate|rotected|rotocol|ublic)|r(aise|e(adonly|do|f|gister|peat|quire(_once)?|scue|strict|try|turn))|s(byte|ealed|elf|hort|igned|izeof|tatic|tring|truct|ubscript|uper|ynchronized|witch)|t(emplate|hen|his|hrows?|ransient|rue|ry|ype(alias|def|id|name|of))|u(n(checked|def(ined)?|ion|less|signed|til)|se|sing)|v(ar|irtual|oid|olatile)|w(char_t|hen|here|hile|ith)|xor|yield)$/.test(f):0]),a.appendChild(t.createTextNode(f))),o=p&&p<7?p:o,f="",p=11;![1,/[\/{}[(\-+*=<>:;|\\.,?!&@~]/.test(c),/[\])]/.test(c),/[$\w]/.test(c),"/"==c&&o<2&&"<"!=n,'"'==c,"'"==c,c+l+u[s+1]+u[s+2]=="\x3c!--",c+l=="/*",c+l=="//","#"==c][--p];);f+=c}}(e)},t.mapToList=function e(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"key";var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:l.default.Map();if(!l.default.Map.isMap(t)||!t.size)return l.default.List();Array.isArray(n)||(n=[n]);if(n.length<1)return t.merge(r);var a=l.default.List();var u=n[0];var s=!0;var c=!1;var f=void 0;try{for(var p,d=(0,i.default)(t.entries());!(s=(p=d.next()).done);s=!0){var h=p.value,v=(0,o.default)(h,2),m=v[0],g=v[1],y=e(g,n.slice(1),r.set(u,m));a=l.default.List.isList(y)?a.concat(y):a.push(y)}}catch(e){c=!0,f=e}finally{try{!s&&d.return&&d.return()}finally{if(c)throw f}}return a},t.extractFileNameFromContentDispositionHeader=function(e){var t=/filename="([^;]*);?"/i.exec(e);null===t&&(t=/filename=([^;]*);?/i.exec(e));if(null!==t&&t.length>1)return t[1];return null},t.pascalCase=C,t.pascalCaseFilename=function(e){return C(e.replace(/\.[^./]*$/,""))},t.sanitizeUrl=function(e){if("string"!=typeof e||""===e)return"";return(0,c.sanitizeUrl)(e)},t.getAcceptControllingResponse=function(e){if(!l.default.OrderedMap.isOrderedMap(e))return null;if(!e.size)return null;var t=e.find(function(e,t){return t.startsWith("2")&&(0,u.default)(e.get("content")||{}).length>0}),n=e.get("default")||l.default.OrderedMap(),r=(n.get("content")||l.default.OrderedMap()).keySeq().toJS().length?n:null;return t||r},t.deeplyStripKey=function e(t,n){var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:function(){return!0};if("object"!==(void 0===t?"undefined":(0,s.default)(t))||Array.isArray(t)||null===t||!n)return t;var o=(0,a.default)({},t);(0,u.default)(o).forEach(function(t){t===n&&r(o[t],t)?delete o[t]:o[t]=e(o[t],n,r)});return o},t.stringify=function(e){if("string"==typeof e)return e;e.toJS&&(e=e.toJS());if("object"===(void 0===e?"undefined":(0,s.default)(e))&&null!==e)try{return(0,r.default)(e,null,2)}catch(t){return String(e)}return e.toString()},t.numberToString=function(e){if("number"==typeof e)return e.toString();return e};var l=_(n(7)),c=n(572),f=_(n(573)),p=_(n(280)),d=_(n(284)),h=_(n(287)),v=_(n(651)),m=_(n(106)),g=n(193),y=_(n(33)),b=_(n(724));function _(e){return e&&e.__esModule?e:{default:e}}var w="default",E=t.isImmutable=function(e){return l.default.Iterable.isIterable(e)};function x(e){return Array.isArray(e)?e:[e]}function S(e){return!!e&&"object"===(void 0===e?"undefined":(0,s.default)(e))}t.memoize=d.default;function C(e){return(0,p.default)((0,f.default)(e))}t.propChecker=function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:[],r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:[];return(0,u.default)(e).length!==(0,u.default)(t).length||((0,v.default)(e,function(e,n){if(r.includes(n))return!1;var o=t[n];return l.default.Iterable.isIterable(e)?!l.default.is(e,o):("object"!==(void 0===e?"undefined":(0,s.default)(e))||"object"!==(void 0===o?"undefined":(0,s.default)(o)))&&e!==o})||n.some(function(n){return!(0,m.default)(e[n],t[n])}))};var k=t.validateMaximum=function(e,t){if(e>t)return"Value must be less than Maximum"},A=t.validateMinimum=function(e,t){if(et)return"Value must be less than MaxLength"},D=t.validateMinLength=function(e,t){if(e.length2&&void 0!==arguments[2]&&arguments[2],r=[],o=t&&"body"===e.get("in")?e.get("value_xml"):e.get("value"),i=e.get("required"),a=n?e.get("schema"):e;if(!a)return r;var u=a.get("maximum"),c=a.get("minimum"),f=a.get("type"),p=a.get("format"),d=a.get("maxLength"),h=a.get("minLength"),v=a.get("pattern");if(f&&(i||o)){var m="string"===f&&o,g="array"===f&&Array.isArray(o)&&o.length,b="array"===f&&l.default.List.isList(o)&&o.count(),_="file"===f&&o instanceof y.default.File,w="boolean"===f&&(o||!1===o),E="number"===f&&(o||0===o),x="integer"===f&&(o||0===o),S=!1;if(n&&"object"===f)if("object"===(void 0===o?"undefined":(0,s.default)(o)))S=!0;else if("string"==typeof o)try{JSON.parse(o),S=!0}catch(e){return r.push("Parameter string value must be valid JSON"),r}var C=[m,g,b,_,w,E,x,S].some(function(e){return!!e});if(i&&!C)return r.push("Required field is not provided"),r;if(v){var U=L(o,v);U&&r.push(U)}if(d||0===d){var q=R(o,d);q&&r.push(q)}if(h){var F=D(o,h);F&&r.push(F)}if(u||0===u){var z=k(o,u);z&&r.push(z)}if(c||0===c){var B=A(o,c);B&&r.push(B)}if("string"===f){var V=void 0;if(!(V="date-time"===p?j(o):"uuid"===p?N(o):I(o)))return r;r.push(V)}else if("boolean"===f){var H=M(o);if(!H)return r;r.push(H)}else if("number"===f){var W=O(o);if(!W)return r;r.push(W)}else if("integer"===f){var J=P(o);if(!J)return r;r.push(J)}else if("array"===f){var Y;if(!b||!o.count())return r;Y=a.getIn(["items","type"]),o.forEach(function(e,t){var n=void 0;"number"===Y?n=O(e):"integer"===Y?n=P(e):"string"===Y&&(n=I(e)),n&&r.push({index:t,error:n})})}else if("file"===f){var K=T(o);if(!K)return r;r.push(K)}}return r},t.getSampleSchema=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};if(/xml/.test(t)){if(!e.xml||!e.xml.name){if(e.xml=e.xml||{},!e.$$ref)return e.type||e.items||e.properties||e.additionalProperties?'\n\x3c!-- XML example cannot be generated --\x3e':null;var o=e.$$ref.match(/\S*\/(\S+)$/);e.xml.name=o[1]}return(0,g.memoizedCreateXMLExample)(e,n)}var i=(0,g.memoizedSampleFromSchema)(e,n);return"object"===(void 0===i?"undefined":(0,s.default)(i))?(0,r.default)(i,null,2):i},t.parseSearch=function(){var e={},t=y.default.location.search;if(!t)return{};if(""!=t){var n=t.substr(1).split("&");for(var r in n)n.hasOwnProperty(r)&&(r=n[r].split("="),e[decodeURIComponent(r[0])]=r[1]&&decodeURIComponent(r[1])||"")}return e},t.serializeSearch=function(e){return(0,u.default)(e).map(function(t){return encodeURIComponent(t)+"="+encodeURIComponent(e[t])}).join("&")},t.btoa=function(t){return(t instanceof e?t:new e(t.toString(),"utf-8")).toString("base64")},t.sorters={operationsSorter:{alpha:function(e,t){return e.get("path").localeCompare(t.get("path"))},method:function(e,t){return e.get("method").localeCompare(t.get("method"))}},tagsSorter:{alpha:function(e,t){return e.localeCompare(t)}}},t.buildFormData=function(e){var t=[];for(var n in e){var r=e[n];void 0!==r&&""!==r&&t.push([n,"=",encodeURIComponent(r).replace(/%20/g,"+")].join(""))}return t.join("&")},t.shallowEqualKeys=function(e,t,n){return!!(0,h.default)(n,function(n){return(0,m.default)(e[n],t[n])})};var U=t.createDeepLinkPath=function(e){return"string"==typeof e||e instanceof String?e.trim().replace(/\s/g,"_"):""};t.escapeDeepLinkPath=function(e){return(0,b.default)(U(e))},t.getExtensions=function(e){return e.filter(function(e,t){return/^x-/.test(t)})},t.getCommonExtensions=function(e){return e.filter(function(e,t){return/^pattern|maxLength|minLength|maximum|minimum/.test(t)})}}).call(t,n(55).Buffer)},function(e,t,n){"use strict";e.exports=function(e){for(var t=arguments.length-1,n="Minified React error #"+e+"; visit http://facebook.github.io/react/docs/error-decoder.html?invariant="+e,r=0;r>",i={listOf:function(e){return l(e,"List",r.List.isList)},mapOf:function(e,t){return c(e,t,"Map",r.Map.isMap)},orderedMapOf:function(e,t){return c(e,t,"OrderedMap",r.OrderedMap.isOrderedMap)},setOf:function(e){return l(e,"Set",r.Set.isSet)},orderedSetOf:function(e){return l(e,"OrderedSet",r.OrderedSet.isOrderedSet)},stackOf:function(e){return l(e,"Stack",r.Stack.isStack)},iterableOf:function(e){return l(e,"Iterable",r.Iterable.isIterable)},recordOf:function(e){return u(function(t,n,o,i,u){for(var s=arguments.length,l=Array(s>5?s-5:0),c=5;c6?s-6:0),c=6;c5?l-5:0),f=5;f5?i-5:0),u=5;u key("+c[f]+")"].concat(a));if(d instanceof Error)return d}})).apply(void 0,i);var s})}function f(e){var t=void 0===arguments[1]?"Iterable":arguments[1],n=void 0===arguments[2]?r.Iterable.isIterable:arguments[2];return u(function(r,o,i,u,s){for(var l=arguments.length,c=Array(l>5?l-5:0),f=5;f?@[\]^_`{|}~-])/g;function a(e){return!(e>=55296&&e<=57343)&&(!(e>=64976&&e<=65007)&&(65535!=(65535&e)&&65534!=(65535&e)&&(!(e>=0&&e<=8)&&(11!==e&&(!(e>=14&&e<=31)&&(!(e>=127&&e<=159)&&!(e>1114111)))))))}function u(e){if(e>65535){var t=55296+((e-=65536)>>10),n=56320+(1023&e);return String.fromCharCode(t,n)}return String.fromCharCode(e)}var s=/&([a-z#][a-z0-9]{1,31});/gi,l=/^#((?:x[a-f0-9]{1,8}|[0-9]{1,8}))/i,c=n(416);function f(e,t){var n=0;return o(c,t)?c[t]:35===t.charCodeAt(0)&&l.test(t)&&a(n="x"===t[1].toLowerCase()?parseInt(t.slice(2),16):parseInt(t.slice(1),10))?u(n):e}var p=/[&<>"]/,d=/[&<>"]/g,h={"&":"&","<":"<",">":">",'"':"""};function v(e){return h[e]}t.assign=function(e){return[].slice.call(arguments,1).forEach(function(t){if(t){if("object"!=typeof t)throw new TypeError(t+"must be object");Object.keys(t).forEach(function(n){e[n]=t[n]})}}),e},t.isString=function(e){return"[object String]"===function(e){return Object.prototype.toString.call(e)}(e)},t.has=o,t.unescapeMd=function(e){return e.indexOf("\\")<0?e:e.replace(i,"$1")},t.isValidEntityCode=a,t.fromCodePoint=u,t.replaceEntities=function(e){return e.indexOf("&")<0?e:e.replace(s,f)},t.escapeHtml=function(e){return p.test(e)?e.replace(d,v):e}},function(e,t){e.exports=function(e){return"object"==typeof e?null!==e:"function"==typeof e}},function(e,t){var n=e.exports="undefined"!=typeof window&&window.Math==Math?window:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")();"number"==typeof __g&&(__g=n)},function(e,t,n){var r=n(29),o=n(53),i=n(59),a=n(73),u=n(120),s=function(e,t,n){var l,c,f,p,d=e&s.F,h=e&s.G,v=e&s.S,m=e&s.P,g=e&s.B,y=h?r:v?r[t]||(r[t]={}):(r[t]||{}).prototype,b=h?o:o[t]||(o[t]={}),_=b.prototype||(b.prototype={});for(l in h&&(n=t),n)f=((c=!d&&y&&void 0!==y[l])?y:n)[l],p=g&&c?u(f,r):m&&"function"==typeof f?u(Function.call,f):f,y&&a(y,l,f,e&s.U),b[l]!=f&&i(b,l,p),m&&_[l]!=f&&(_[l]=f)};r.core=o,s.F=1,s.G=2,s.S=4,s.P=8,s.B=16,s.W=32,s.U=64,s.R=128,e.exports=s},function(e,t,n){var r=n(30),o=n(102),i=n(54),a=/"/g,u=function(e,t,n,r){var o=String(i(e)),u="<"+t;return""!==n&&(u+=" "+n+'="'+String(r).replace(a,""")+'"'),u+">"+o+""};e.exports=function(e,t){var n={};n[e]=t(u),r(r.P+r.F*o(function(){var t=""[e]('"');return t!==t.toLowerCase()||t.split('"').length>3}),"String",n)}},function(e,t){var n;n=function(){return this}();try{n=n||Function("return this")()||(0,eval)("this")}catch(e){"object"==typeof window&&(n=window)}e.exports=n},function(e,t,n){"use strict";var r,o=n(91),i=(r=o)&&r.__esModule?r:{default:r};e.exports=function(){var e={location:{},history:{},open:function(){},close:function(){},File:function(){}};if("undefined"==typeof window)return e;try{e=window;var t=!0,n=!1,r=void 0;try{for(var o,a=(0,i.default)(["File","Blob","FormData"]);!(t=(o=a.next()).done);t=!0){var u=o.value;u in window&&(e[u]=window[u])}}catch(e){n=!0,r=e}finally{try{!t&&a.return&&a.return()}finally{if(n)throw r}}}catch(e){console.error(e)}return e}()},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=i(n(25));t.isOAS3=a,t.isSwagger2=function(e){var t=e.get("swagger");if("string"!=typeof t)return!1;return t.startsWith("2.0")},t.OAS3ComponentWrapFactory=function(e){return function(t,n){return function(i){if(n&&n.specSelectors&&n.specSelectors.specJson){var u=n.specSelectors.specJson();return a(u)?o.default.createElement(e,(0,r.default)({},i,n,{Ori:t})):o.default.createElement(t,i)}return console.warn("OAS3 wrapper: couldn't get spec"),null}}};var o=i(n(0));function i(e){return e&&e.__esModule?e:{default:e}}function a(e){var t=e.get("openapi");return"string"==typeof t&&(t.startsWith("3.0.")&&t.length>4)}},function(e,t,n){var r=n(28);e.exports=function(e){if(!r(e))throw TypeError(e+" is not an object!");return e}},function(e,t,n){var r=n(278),o="object"==typeof self&&self&&self.Object===Object&&self,i=r||o||Function("return this")();e.exports=i},function(e,t){e.exports=function(e){var t=typeof e;return null!=e&&("object"==t||"function"==t)}},function(e,t,n){"use strict";var r=null;e.exports={debugTool:r}},function(e,t,n){var r=n(35),o=n(239),i=n(157),a=Object.defineProperty;t.f=n(44)?Object.defineProperty:function(e,t,n){if(r(e),t=i(t,!0),r(n),o)try{return a(e,t,n)}catch(e){}if("get"in n||"set"in n)throw TypeError("Accessors not supported!");return"value"in n&&(e[t]=n.value),e}},function(e,t,n){e.exports={default:n(517),__esModule:!0}},function(e,t,n){e.exports={default:n(518),__esModule:!0}},function(e,t,n){"use strict";function r(e){return function(){return e}}var o=function(){};o.thatReturns=r,o.thatReturnsFalse=r(!1),o.thatReturnsTrue=r(!0),o.thatReturnsNull=r(null),o.thatReturnsThis=function(){return this},o.thatReturnsArgument=function(e){return e},e.exports=o},function(e,t,n){"use strict";var r=n(10),o=n(13),i=n(354),a=n(69),u=n(355),s=n(88),l=n(147),c=n(8),f=[],p=0,d=i.getPooled(),h=!1,v=null;function m(){E.ReactReconcileTransaction&&v||r("123")}var g=[{initialize:function(){this.dirtyComponentsLength=f.length},close:function(){this.dirtyComponentsLength!==f.length?(f.splice(0,this.dirtyComponentsLength),w()):f.length=0}},{initialize:function(){this.callbackQueue.reset()},close:function(){this.callbackQueue.notifyAll()}}];function y(){this.reinitializeTransaction(),this.dirtyComponentsLength=null,this.callbackQueue=i.getPooled(),this.reconcileTransaction=E.ReactReconcileTransaction.getPooled(!0)}function b(e,t){return e._mountOrder-t._mountOrder}function _(e){var t=e.dirtyComponentsLength;t!==f.length&&r("124",t,f.length),f.sort(b),p++;for(var n=0;n + * @license MIT + */ +var r=n(529),o=n(530),i=n(261);function a(){return s.TYPED_ARRAY_SUPPORT?2147483647:1073741823}function u(e,t){if(a()=a())throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+a().toString(16)+" bytes");return 0|e}function h(e,t){if(s.isBuffer(e))return e.length;if("undefined"!=typeof ArrayBuffer&&"function"==typeof ArrayBuffer.isView&&(ArrayBuffer.isView(e)||e instanceof ArrayBuffer))return e.byteLength;"string"!=typeof e&&(e=""+e);var n=e.length;if(0===n)return 0;for(var r=!1;;)switch(t){case"ascii":case"latin1":case"binary":return n;case"utf8":case"utf-8":case void 0:return F(e).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return 2*n;case"hex":return n>>>1;case"base64":return z(e).length;default:if(r)return F(e).length;t=(""+t).toLowerCase(),r=!0}}function v(e,t,n){var r=e[t];e[t]=e[n],e[n]=r}function m(e,t,n,r,o){if(0===e.length)return-1;if("string"==typeof n?(r=n,n=0):n>2147483647?n=2147483647:n<-2147483648&&(n=-2147483648),n=+n,isNaN(n)&&(n=o?0:e.length-1),n<0&&(n=e.length+n),n>=e.length){if(o)return-1;n=e.length-1}else if(n<0){if(!o)return-1;n=0}if("string"==typeof t&&(t=s.from(t,r)),s.isBuffer(t))return 0===t.length?-1:g(e,t,n,r,o);if("number"==typeof t)return t&=255,s.TYPED_ARRAY_SUPPORT&&"function"==typeof Uint8Array.prototype.indexOf?o?Uint8Array.prototype.indexOf.call(e,t,n):Uint8Array.prototype.lastIndexOf.call(e,t,n):g(e,[t],n,r,o);throw new TypeError("val must be string, number or Buffer")}function g(e,t,n,r,o){var i,a=1,u=e.length,s=t.length;if(void 0!==r&&("ucs2"===(r=String(r).toLowerCase())||"ucs-2"===r||"utf16le"===r||"utf-16le"===r)){if(e.length<2||t.length<2)return-1;a=2,u/=2,s/=2,n/=2}function l(e,t){return 1===a?e[t]:e.readUInt16BE(t*a)}if(o){var c=-1;for(i=n;iu&&(n=u-s),i=n;i>=0;i--){for(var f=!0,p=0;po&&(r=o):r=o;var i=t.length;if(i%2!=0)throw new TypeError("Invalid hex string");r>i/2&&(r=i/2);for(var a=0;a>8,o=n%256,i.push(o),i.push(r);return i}(t,e.length-n),e,n,r)}function S(e,t,n){return 0===t&&n===e.length?r.fromByteArray(e):r.fromByteArray(e.slice(t,n))}function C(e,t,n){n=Math.min(e.length,n);for(var r=[],o=t;o239?4:l>223?3:l>191?2:1;if(o+f<=n)switch(f){case 1:l<128&&(c=l);break;case 2:128==(192&(i=e[o+1]))&&(s=(31&l)<<6|63&i)>127&&(c=s);break;case 3:i=e[o+1],a=e[o+2],128==(192&i)&&128==(192&a)&&(s=(15&l)<<12|(63&i)<<6|63&a)>2047&&(s<55296||s>57343)&&(c=s);break;case 4:i=e[o+1],a=e[o+2],u=e[o+3],128==(192&i)&&128==(192&a)&&128==(192&u)&&(s=(15&l)<<18|(63&i)<<12|(63&a)<<6|63&u)>65535&&s<1114112&&(c=s)}null===c?(c=65533,f=1):c>65535&&(c-=65536,r.push(c>>>10&1023|55296),c=56320|1023&c),r.push(c),o+=f}return function(e){var t=e.length;if(t<=k)return String.fromCharCode.apply(String,e);var n="",r=0;for(;rthis.length)return"";if((void 0===n||n>this.length)&&(n=this.length),n<=0)return"";if((n>>>=0)<=(t>>>=0))return"";for(e||(e="utf8");;)switch(e){case"hex":return P(this,t,n);case"utf8":case"utf-8":return C(this,t,n);case"ascii":return A(this,t,n);case"latin1":case"binary":return O(this,t,n);case"base64":return S(this,t,n);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return T(this,t,n);default:if(r)throw new TypeError("Unknown encoding: "+e);e=(e+"").toLowerCase(),r=!0}}.apply(this,arguments)},s.prototype.equals=function(e){if(!s.isBuffer(e))throw new TypeError("Argument must be a Buffer");return this===e||0===s.compare(this,e)},s.prototype.inspect=function(){var e="",n=t.INSPECT_MAX_BYTES;return this.length>0&&(e=this.toString("hex",0,n).match(/.{2}/g).join(" "),this.length>n&&(e+=" ... ")),""},s.prototype.compare=function(e,t,n,r,o){if(!s.isBuffer(e))throw new TypeError("Argument must be a Buffer");if(void 0===t&&(t=0),void 0===n&&(n=e?e.length:0),void 0===r&&(r=0),void 0===o&&(o=this.length),t<0||n>e.length||r<0||o>this.length)throw new RangeError("out of range index");if(r>=o&&t>=n)return 0;if(r>=o)return-1;if(t>=n)return 1;if(t>>>=0,n>>>=0,r>>>=0,o>>>=0,this===e)return 0;for(var i=o-r,a=n-t,u=Math.min(i,a),l=this.slice(r,o),c=e.slice(t,n),f=0;fo)&&(n=o),e.length>0&&(n<0||t<0)||t>this.length)throw new RangeError("Attempt to write outside buffer bounds");r||(r="utf8");for(var i=!1;;)switch(r){case"hex":return y(this,e,t,n);case"utf8":case"utf-8":return b(this,e,t,n);case"ascii":return _(this,e,t,n);case"latin1":case"binary":return w(this,e,t,n);case"base64":return E(this,e,t,n);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return x(this,e,t,n);default:if(i)throw new TypeError("Unknown encoding: "+r);r=(""+r).toLowerCase(),i=!0}},s.prototype.toJSON=function(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};var k=4096;function A(e,t,n){var r="";n=Math.min(e.length,n);for(var o=t;or)&&(n=r);for(var o="",i=t;in)throw new RangeError("Trying to access beyond buffer length")}function I(e,t,n,r,o,i){if(!s.isBuffer(e))throw new TypeError('"buffer" argument must be a Buffer instance');if(t>o||te.length)throw new RangeError("Index out of range")}function j(e,t,n,r){t<0&&(t=65535+t+1);for(var o=0,i=Math.min(e.length-n,2);o>>8*(r?o:1-o)}function N(e,t,n,r){t<0&&(t=4294967295+t+1);for(var o=0,i=Math.min(e.length-n,4);o>>8*(r?o:3-o)&255}function R(e,t,n,r,o,i){if(n+r>e.length)throw new RangeError("Index out of range");if(n<0)throw new RangeError("Index out of range")}function D(e,t,n,r,i){return i||R(e,0,n,4),o.write(e,t,n,r,23,4),n+4}function L(e,t,n,r,i){return i||R(e,0,n,8),o.write(e,t,n,r,52,8),n+8}s.prototype.slice=function(e,t){var n,r=this.length;if(e=~~e,t=void 0===t?r:~~t,e<0?(e+=r)<0&&(e=0):e>r&&(e=r),t<0?(t+=r)<0&&(t=0):t>r&&(t=r),t0&&(o*=256);)r+=this[e+--t]*o;return r},s.prototype.readUInt8=function(e,t){return t||M(e,1,this.length),this[e]},s.prototype.readUInt16LE=function(e,t){return t||M(e,2,this.length),this[e]|this[e+1]<<8},s.prototype.readUInt16BE=function(e,t){return t||M(e,2,this.length),this[e]<<8|this[e+1]},s.prototype.readUInt32LE=function(e,t){return t||M(e,4,this.length),(this[e]|this[e+1]<<8|this[e+2]<<16)+16777216*this[e+3]},s.prototype.readUInt32BE=function(e,t){return t||M(e,4,this.length),16777216*this[e]+(this[e+1]<<16|this[e+2]<<8|this[e+3])},s.prototype.readIntLE=function(e,t,n){e|=0,t|=0,n||M(e,t,this.length);for(var r=this[e],o=1,i=0;++i=(o*=128)&&(r-=Math.pow(2,8*t)),r},s.prototype.readIntBE=function(e,t,n){e|=0,t|=0,n||M(e,t,this.length);for(var r=t,o=1,i=this[e+--r];r>0&&(o*=256);)i+=this[e+--r]*o;return i>=(o*=128)&&(i-=Math.pow(2,8*t)),i},s.prototype.readInt8=function(e,t){return t||M(e,1,this.length),128&this[e]?-1*(255-this[e]+1):this[e]},s.prototype.readInt16LE=function(e,t){t||M(e,2,this.length);var n=this[e]|this[e+1]<<8;return 32768&n?4294901760|n:n},s.prototype.readInt16BE=function(e,t){t||M(e,2,this.length);var n=this[e+1]|this[e]<<8;return 32768&n?4294901760|n:n},s.prototype.readInt32LE=function(e,t){return t||M(e,4,this.length),this[e]|this[e+1]<<8|this[e+2]<<16|this[e+3]<<24},s.prototype.readInt32BE=function(e,t){return t||M(e,4,this.length),this[e]<<24|this[e+1]<<16|this[e+2]<<8|this[e+3]},s.prototype.readFloatLE=function(e,t){return t||M(e,4,this.length),o.read(this,e,!0,23,4)},s.prototype.readFloatBE=function(e,t){return t||M(e,4,this.length),o.read(this,e,!1,23,4)},s.prototype.readDoubleLE=function(e,t){return t||M(e,8,this.length),o.read(this,e,!0,52,8)},s.prototype.readDoubleBE=function(e,t){return t||M(e,8,this.length),o.read(this,e,!1,52,8)},s.prototype.writeUIntLE=function(e,t,n,r){(e=+e,t|=0,n|=0,r)||I(this,e,t,n,Math.pow(2,8*n)-1,0);var o=1,i=0;for(this[t]=255&e;++i=0&&(i*=256);)this[t+o]=e/i&255;return t+n},s.prototype.writeUInt8=function(e,t,n){return e=+e,t|=0,n||I(this,e,t,1,255,0),s.TYPED_ARRAY_SUPPORT||(e=Math.floor(e)),this[t]=255&e,t+1},s.prototype.writeUInt16LE=function(e,t,n){return e=+e,t|=0,n||I(this,e,t,2,65535,0),s.TYPED_ARRAY_SUPPORT?(this[t]=255&e,this[t+1]=e>>>8):j(this,e,t,!0),t+2},s.prototype.writeUInt16BE=function(e,t,n){return e=+e,t|=0,n||I(this,e,t,2,65535,0),s.TYPED_ARRAY_SUPPORT?(this[t]=e>>>8,this[t+1]=255&e):j(this,e,t,!1),t+2},s.prototype.writeUInt32LE=function(e,t,n){return e=+e,t|=0,n||I(this,e,t,4,4294967295,0),s.TYPED_ARRAY_SUPPORT?(this[t+3]=e>>>24,this[t+2]=e>>>16,this[t+1]=e>>>8,this[t]=255&e):N(this,e,t,!0),t+4},s.prototype.writeUInt32BE=function(e,t,n){return e=+e,t|=0,n||I(this,e,t,4,4294967295,0),s.TYPED_ARRAY_SUPPORT?(this[t]=e>>>24,this[t+1]=e>>>16,this[t+2]=e>>>8,this[t+3]=255&e):N(this,e,t,!1),t+4},s.prototype.writeIntLE=function(e,t,n,r){if(e=+e,t|=0,!r){var o=Math.pow(2,8*n-1);I(this,e,t,n,o-1,-o)}var i=0,a=1,u=0;for(this[t]=255&e;++i>0)-u&255;return t+n},s.prototype.writeIntBE=function(e,t,n,r){if(e=+e,t|=0,!r){var o=Math.pow(2,8*n-1);I(this,e,t,n,o-1,-o)}var i=n-1,a=1,u=0;for(this[t+i]=255&e;--i>=0&&(a*=256);)e<0&&0===u&&0!==this[t+i+1]&&(u=1),this[t+i]=(e/a>>0)-u&255;return t+n},s.prototype.writeInt8=function(e,t,n){return e=+e,t|=0,n||I(this,e,t,1,127,-128),s.TYPED_ARRAY_SUPPORT||(e=Math.floor(e)),e<0&&(e=255+e+1),this[t]=255&e,t+1},s.prototype.writeInt16LE=function(e,t,n){return e=+e,t|=0,n||I(this,e,t,2,32767,-32768),s.TYPED_ARRAY_SUPPORT?(this[t]=255&e,this[t+1]=e>>>8):j(this,e,t,!0),t+2},s.prototype.writeInt16BE=function(e,t,n){return e=+e,t|=0,n||I(this,e,t,2,32767,-32768),s.TYPED_ARRAY_SUPPORT?(this[t]=e>>>8,this[t+1]=255&e):j(this,e,t,!1),t+2},s.prototype.writeInt32LE=function(e,t,n){return e=+e,t|=0,n||I(this,e,t,4,2147483647,-2147483648),s.TYPED_ARRAY_SUPPORT?(this[t]=255&e,this[t+1]=e>>>8,this[t+2]=e>>>16,this[t+3]=e>>>24):N(this,e,t,!0),t+4},s.prototype.writeInt32BE=function(e,t,n){return e=+e,t|=0,n||I(this,e,t,4,2147483647,-2147483648),e<0&&(e=4294967295+e+1),s.TYPED_ARRAY_SUPPORT?(this[t]=e>>>24,this[t+1]=e>>>16,this[t+2]=e>>>8,this[t+3]=255&e):N(this,e,t,!1),t+4},s.prototype.writeFloatLE=function(e,t,n){return D(this,e,t,!0,n)},s.prototype.writeFloatBE=function(e,t,n){return D(this,e,t,!1,n)},s.prototype.writeDoubleLE=function(e,t,n){return L(this,e,t,!0,n)},s.prototype.writeDoubleBE=function(e,t,n){return L(this,e,t,!1,n)},s.prototype.copy=function(e,t,n,r){if(n||(n=0),r||0===r||(r=this.length),t>=e.length&&(t=e.length),t||(t=0),r>0&&r=this.length)throw new RangeError("sourceStart out of bounds");if(r<0)throw new RangeError("sourceEnd out of bounds");r>this.length&&(r=this.length),e.length-t=0;--o)e[o+t]=this[o+n];else if(i<1e3||!s.TYPED_ARRAY_SUPPORT)for(o=0;o>>=0,n=void 0===n?this.length:n>>>0,e||(e=0),"number"==typeof e)for(i=t;i55295&&n<57344){if(!o){if(n>56319){(t-=3)>-1&&i.push(239,191,189);continue}if(a+1===r){(t-=3)>-1&&i.push(239,191,189);continue}o=n;continue}if(n<56320){(t-=3)>-1&&i.push(239,191,189),o=n;continue}n=65536+(o-55296<<10|n-56320)}else o&&(t-=3)>-1&&i.push(239,191,189);if(o=null,n<128){if((t-=1)<0)break;i.push(n)}else if(n<2048){if((t-=2)<0)break;i.push(n>>6|192,63&n|128)}else if(n<65536){if((t-=3)<0)break;i.push(n>>12|224,n>>6&63|128,63&n|128)}else{if(!(n<1114112))throw new Error("Invalid code point");if((t-=4)<0)break;i.push(n>>18|240,n>>12&63|128,n>>6&63|128,63&n|128)}}return i}function z(e){return r.toByteArray(function(e){if((e=function(e){return e.trim?e.trim():e.replace(/^\s+|\s+$/g,"")}(e).replace(U,"")).length<2)return"";for(;e.length%4!=0;)e+="=";return e}(e))}function B(e,t,n,r){for(var o=0;o=t.length||o>=e.length);++o)t[o+n]=e[o];return o}}).call(t,n(32))},function(e,t){var n,r,o=e.exports={};function i(){throw new Error("setTimeout has not been defined")}function a(){throw new Error("clearTimeout has not been defined")}function u(e){if(n===setTimeout)return setTimeout(e,0);if((n===i||!n)&&setTimeout)return n=setTimeout,setTimeout(e,0);try{return n(e,0)}catch(t){try{return n.call(null,e,0)}catch(t){return n.call(this,e,0)}}}!function(){try{n="function"==typeof setTimeout?setTimeout:i}catch(e){n=i}try{r="function"==typeof clearTimeout?clearTimeout:a}catch(e){r=a}}();var s,l=[],c=!1,f=-1;function p(){c&&s&&(c=!1,s.length?l=s.concat(l):f=-1,l.length&&d())}function d(){if(!c){var e=u(p);c=!0;for(var t=l.length;t;){for(s=l,l=[];++f1)for(var n=1;n1?t-1:0),r=1;r2?n-2:0),o=2;o1){for(var h=Array(d),v=0;v1){for(var g=Array(m),y=0;y=0||Object.prototype.hasOwnProperty.call(e,r)&&(n[r]=e[r]);return n}},function(e,t,n){"use strict";function r(e){return void 0===e||null===e}e.exports.isNothing=r,e.exports.isObject=function(e){return"object"==typeof e&&null!==e},e.exports.toArray=function(e){return Array.isArray(e)?e:r(e)?[]:[e]},e.exports.repeat=function(e,t){var n,r="";for(n=0;n=t.length?{value:void 0,done:!0}:(e=r(t,n),this._i+=e.length,{value:e,done:!1})})},function(e,t){var n={}.toString;e.exports=function(e){return n.call(e).slice(8,-1)}},function(e,t,n){e.exports=!n(102)(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},function(e,t){e.exports=function(e){try{return!!e()}catch(e){return!0}}},function(e,t){e.exports={}},function(e,t,n){var r=n(119),o=Math.min;e.exports=function(e){return e>0?o(r(e),9007199254740991):0}},function(e,t,n){"use strict";e.exports=function(e){for(var t=arguments.length-1,n="Minified React error #"+e+"; visit http://facebook.github.io/react/docs/error-decoder.html?invariant="+e,r=0;r0?o(r(e),9007199254740991):0}},function(e,t){var n=0,r=Math.random();e.exports=function(e){return"Symbol(".concat(void 0===e?"":e,")_",(++n+r).toString(36))}},function(e,t,n){var r=n(60),o=n(459),i=n(460),a=Object.defineProperty;t.f=n(101)?Object.defineProperty:function(e,t,n){if(r(e),t=i(t,!0),r(n),o)try{return a(e,t,n)}catch(e){}if("get"in n||"set"in n)throw TypeError("Accessors not supported!");return"value"in n&&(e[t]=n.value),e}},function(e,t){var n={}.hasOwnProperty;e.exports=function(e,t){return n.call(e,t)}},function(e,t){var n=Math.ceil,r=Math.floor;e.exports=function(e){return isNaN(e=+e)?0:(e>0?r:n)(e)}},function(e,t,n){var r=n(121);e.exports=function(e,t,n){if(r(e),void 0===t)return e;switch(n){case 1:return function(n){return e.call(t,n)};case 2:return function(n,r){return e.call(t,n,r)};case 3:return function(n,r,o){return e.call(t,n,r,o)}}return function(){return e.apply(t,arguments)}}},function(e,t){e.exports=function(e){if("function"!=typeof e)throw TypeError(e+" is not a function!");return e}},function(e,t,n){var r=n(465),o=n(54);e.exports=function(e){return r(o(e))}},function(e,t,n){"use strict";var r=n(59),o=n(73),i=n(102),a=n(54),u=n(17);e.exports=function(e,t,n){var s=u(e),l=n(a,s,""[e]),c=l[0],f=l[1];i(function(){var t={};return t[s]=function(){return 7},7!=""[e](t)})&&(o(String.prototype,e,c),r(RegExp.prototype,s,2==t?function(e,t){return f.call(e,this,t)}:function(e){return f.call(e,this)}))}},function(e,t,n){var r=n(116)("meta"),o=n(28),i=n(52),a=n(39).f,u=0,s=Object.isExtensible||function(){return!0},l=!n(51)(function(){return s(Object.preventExtensions({}))}),c=function(e){a(e,r,{value:{i:"O"+ ++u,w:{}}})},f=e.exports={KEY:r,NEED:!1,fastKey:function(e,t){if(!o(e))return"symbol"==typeof e?e:("string"==typeof e?"S":"P")+e;if(!i(e,r)){if(!s(e))return"F";if(!t)return"E";c(e)}return e[r].i},getWeak:function(e,t){if(!i(e,r)){if(!s(e))return!0;if(!t)return!1;c(e)}return e[r].w},onFreeze:function(e){return l&&f.NEED&&s(e)&&!i(e,r)&&c(e),e}}},function(e,t){t.f={}.propertyIsEnumerable},function(e,t,n){"use strict";var r={};e.exports=r},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.CLEAR_BY=t.CLEAR=t.NEW_AUTH_ERR=t.NEW_SPEC_ERR_BATCH=t.NEW_SPEC_ERR=t.NEW_THROWN_ERR_BATCH=t.NEW_THROWN_ERR=void 0,t.newThrownErr=function(e){return{type:a,payload:(0,i.default)(e)}},t.newThrownErrBatch=function(e){return{type:u,payload:e}},t.newSpecErr=function(e){return{type:s,payload:e}},t.newSpecErrBatch=function(e){return{type:l,payload:e}},t.newAuthErr=function(e){return{type:c,payload:e}},t.clear=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return{type:f,payload:e}},t.clearBy=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:function(){return!0};return{type:p,payload:e}};var r,o=n(180),i=(r=o)&&r.__esModule?r:{default:r};var a=t.NEW_THROWN_ERR="err_new_thrown_err",u=t.NEW_THROWN_ERR_BATCH="err_new_thrown_err_batch",s=t.NEW_SPEC_ERR="err_new_spec_err",l=t.NEW_SPEC_ERR_BATCH="err_new_spec_err_batch",c=t.NEW_AUTH_ERR="err_new_auth_err",f=t.CLEAR="err_clear",p=t.CLEAR_BY="err_clear_by"},function(e,t,n){var r=n(62),o=n(47),i="[object Symbol]";e.exports=function(e){return"symbol"==typeof e||o(e)&&r(e)==i}},function(e,t,n){var r=n(63)(Object,"create");e.exports=r},function(e,t,n){var r=n(601),o=n(602),i=n(603),a=n(604),u=n(605);function s(e){var t=-1,n=null==e?0:e.length;for(this.clear();++t-1&&e%1==0&&eb;b++)if((m=t?y(a(h=e[b])[0],h[1]):y(e[b]))===l||m===c)return m}else for(v=g.call(e);!(h=v.next()).done;)if((m=o(v,y,h.value,t))===l||m===c)return m}).BREAK=l,t.RETURN=c},function(e,t,n){"use strict";var r=n(86);e.exports=r.DEFAULT=new r({include:[n(109)],explicit:[n(759),n(760),n(761)]})},function(e,t,n){var r=n(344),o=n(106),i=Object.prototype.hasOwnProperty;e.exports=function(e,t,n){var a=e[t];i.call(e,t)&&o(a,n)&&(void 0!==n||t in e)||r(e,t,n)}},function(e,t,n){"use strict";var r=n(10),o=(n(8),{}),i={reinitializeTransaction:function(){this.transactionWrappers=this.getTransactionWrappers(),this.wrapperInitData?this.wrapperInitData.length=0:this.wrapperInitData=[],this._isInTransaction=!1},_isInTransaction:!1,getTransactionWrappers:null,isInTransaction:function(){return!!this._isInTransaction},perform:function(e,t,n,o,i,a,u,s){var l,c;this.isInTransaction()&&r("27");try{this._isInTransaction=!0,l=!0,this.initializeAll(0),c=e.call(t,n,o,i,a,u,s),l=!1}finally{try{if(l)try{this.closeAll(0)}catch(e){}else this.closeAll(0)}finally{this._isInTransaction=!1}}return c},initializeAll:function(e){for(var t=this.transactionWrappers,n=e;n]/,s=n(219)(function(e,t){if(e.namespaceURI!==i.svg||"innerHTML"in e)e.innerHTML=t;else{(r=r||document.createElement("div")).innerHTML=""+t+"";for(var n=r.firstChild;n.firstChild;)e.appendChild(n.firstChild)}});if(o.canUseDOM){var l=document.createElement("div");l.innerHTML=" ",""===l.innerHTML&&(s=function(e,t){if(e.parentNode&&e.parentNode.replaceChild(e,e),a.test(t)||"<"===t[0]&&u.test(t)){e.innerHTML=String.fromCharCode(65279)+t;var n=e.firstChild;1===n.data.length?e.removeChild(n):n.deleteData(0,1)}else e.innerHTML=t}),l=null}e.exports=s},function(e,t,n){"use strict";var r=/["'&<>]/;e.exports=function(e){return"boolean"==typeof e||"number"==typeof e?""+e:function(e){var t,n=""+e,o=r.exec(n);if(!o)return n;var i="",a=0,u=0;for(a=o.index;adocument.F=Object<\/script>"),e.close(),s=e.F;r--;)delete s.prototype[i[r]];return s()};e.exports=Object.create||function(e,t){var n;return null!==e?(u.prototype=r(e),n=new u,u.prototype=null,n[a]=e):n=s(),void 0===t?n:o(n,t)}},function(e,t){var n=Math.ceil,r=Math.floor;e.exports=function(e){return isNaN(e=+e)?0:(e>0?r:n)(e)}},function(e,t,n){var r=n(162)("keys"),o=n(116);e.exports=function(e){return r[e]||(r[e]=o(e))}},function(e,t,n){var r=n(14),o=n(19),i=o["__core-js_shared__"]||(o["__core-js_shared__"]={});(e.exports=function(e,t){return i[e]||(i[e]=void 0!==t?t:{})})("versions",[]).push({version:r.version,mode:n(94)?"pure":"global",copyright:"© 2018 Denis Pushkarev (zloirock.ru)"})},function(e,t){e.exports="constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf".split(",")},function(e,t,n){var r=n(165),o=n(20)("iterator"),i=n(70);e.exports=n(14).getIteratorMethod=function(e){if(void 0!=e)return e[o]||e["@@iterator"]||i[r(e)]}},function(e,t,n){var r=n(93),o=n(20)("toStringTag"),i="Arguments"==r(function(){return arguments}());e.exports=function(e){var t,n,a;return void 0===e?"Undefined":null===e?"Null":"string"==typeof(n=function(e,t){try{return e[t]}catch(e){}}(t=Object(e),o))?n:i?r(t):"Object"==(a=r(t))&&"function"==typeof t.callee?"Arguments":a}},function(e,t,n){var r=n(100),o=n(17)("toStringTag"),i="Arguments"==r(function(){return arguments}());e.exports=function(e){var t,n,a;return void 0===e?"Undefined":null===e?"Null":"string"==typeof(n=function(e,t){try{return e[t]}catch(e){}}(t=Object(e),o))?n:i?r(t):"Object"==(a=r(t))&&"function"==typeof t.callee?"Arguments":a}},function(e,t){e.exports=!1},function(e,t){var n=0,r=Math.random();e.exports=function(e){return"Symbol(".concat(void 0===e?"":e,")_",(++n+r).toString(36))}},function(e,t,n){var r=n(74),o=n(29).document,i=r(o)&&r(o.createElement);e.exports=function(e){return i?o.createElement(e):{}}},function(e,t,n){var r=n(243)("keys"),o=n(168);e.exports=function(e){return r[e]||(r[e]=o(e))}},function(e,t,n){var r=n(117).f,o=n(118),i=n(17)("toStringTag");e.exports=function(e,t,n){e&&!o(e=n?e:e.prototype,i)&&r(e,i,{configurable:!0,value:t})}},function(e,t,n){"use strict";var r=n(121);e.exports.f=function(e){return new function(e){var t,n;this.promise=new e(function(e,r){if(void 0!==t||void 0!==n)throw TypeError("Bad Promise constructor");t=e,n=r}),this.resolve=r(t),this.reject=r(n)}(e)}},function(e,t,n){var r=n(256),o=n(54);e.exports=function(e,t,n){if(r(t))throw TypeError("String#"+n+" doesn't accept regex!");return String(o(e))}},function(e,t,n){var r=n(17)("match");e.exports=function(e){var t=/./;try{"/./"[e](t)}catch(n){try{return t[r]=!1,!"/./"[e](t)}catch(e){}}return!0}},function(e,t,n){t.f=n(20)},function(e,t,n){var r=n(19),o=n(14),i=n(94),a=n(175),u=n(39).f;e.exports=function(e){var t=o.Symbol||(o.Symbol=i?{}:r.Symbol||{});"_"==e.charAt(0)||e in t||u(t,e,{value:a.f(e)})}},function(e,t){t.f=Object.getOwnPropertySymbols},function(e,t){},function(e,t,n){"use strict";(function(t){ +/*! + * @description Recursive object extending + * @author Viacheslav Lotsmanov + * @license MIT + * + * The MIT License (MIT) + * + * Copyright (c) 2013-2018 Viacheslav Lotsmanov + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +function n(e){return e instanceof t||e instanceof Date||e instanceof RegExp}function r(e){if(e instanceof t){var n=t.alloc?t.alloc(e.length):new t(e.length);return e.copy(n),n}if(e instanceof Date)return new Date(e.getTime());if(e instanceof RegExp)return new RegExp(e);throw new Error("Unexpected situation")}function o(e,t){return"__proto__"===t?void 0:e[t]}var i=e.exports=function(){if(arguments.length<1||"object"!=typeof arguments[0])return!1;if(arguments.length<2)return arguments[0];var e,t,a=arguments[0];return Array.prototype.slice.call(arguments,1).forEach(function(u){"object"!=typeof u||null===u||Array.isArray(u)||Object.keys(u).forEach(function(s){return t=o(a,s),(e=o(u,s))===a?void 0:"object"!=typeof e||null===e?void(a[s]=e):Array.isArray(e)?void(a[s]=function e(t){var o=[];return t.forEach(function(t,a){"object"==typeof t&&null!==t?Array.isArray(t)?o[a]=e(t):n(t)?o[a]=r(t):o[a]=i({},t):o[a]=t}),o}(e)):n(e)?void(a[s]=r(e)):"object"!=typeof t||null===t||Array.isArray(t)?void(a[s]=i({},e)):void(a[s]=i(t,e))})}),a}}).call(t,n(55).Buffer)},function(e,t,n){"use strict";e.exports=function(e){return"object"==typeof e?function e(t,n){var r;r=Array.isArray(t)?[]:{};n.push(t);Object.keys(t).forEach(function(o){var i=t[o];"function"!=typeof i&&(i&&"object"==typeof i?-1!==n.indexOf(t[o])?r[o]="[Circular]":r[o]=e(t[o],n.slice(0)):r[o]=i)});"string"==typeof t.name&&(r.name=t.name);"string"==typeof t.message&&(r.message=t.message);"string"==typeof t.stack&&(r.stack=t.stack);return r}(e,[]):"function"==typeof e?"[Function: "+(e.name||"anonymous")+"]":e}},function(e,t,n){var r=n(590),o=n(606),i=n(608),a=n(609),u=n(610);function s(e){var t=-1,n=null==e?0:e.length;for(this.clear();++t-1&&e%1==0&&e<=n}},function(e,t){e.exports=function(e){return function(t){return e(t)}}},function(e,t,n){(function(e){var r=n(278),o="object"==typeof t&&t&&!t.nodeType&&t,i=o&&"object"==typeof e&&e&&!e.nodeType&&e,a=i&&i.exports===o&&r.process,u=function(){try{var e=i&&i.require&&i.require("util").types;return e||a&&a.binding&&a.binding("util")}catch(e){}}();e.exports=u}).call(t,n(134)(e))},function(e,t,n){var r=n(24),o=n(128),i=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,a=/^\w*$/;e.exports=function(e,t){if(r(e))return!1;var n=typeof e;return!("number"!=n&&"symbol"!=n&&"boolean"!=n&&null!=e&&!o(e))||a.test(e)||!i.test(e)||null!=t&&e in Object(t)}},function(e,t){e.exports=function(e){return e}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.memoizedSampleFromSchema=t.memoizedCreateXMLExample=t.sampleXmlFromSchema=t.inferSchema=t.sampleFromSchema=void 0,t.createXMLExample=f;var r=n(9),o=a(n(657)),i=a(n(670));function a(e){return e&&e.__esModule?e:{default:e}}var u={string:function(){return"string"},string_email:function(){return"user@example.com"},"string_date-time":function(){return(new Date).toISOString()},number:function(){return 0},number_float:function(){return 0},integer:function(){return 0},boolean:function(e){return"boolean"!=typeof e.default||e.default}},s=function(e){var t=e=(0,r.objectify)(e),n=t.type,o=t.format,i=u[n+"_"+o]||u[n];return(0,r.isFunc)(i)?i(e):"Unknown Type: "+e.type},l=t.sampleFromSchema=function e(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},o=(0,r.objectify)(t),i=o.type,a=o.example,u=o.properties,l=o.additionalProperties,c=o.items,f=n.includeReadOnly,p=n.includeWriteOnly;if(void 0!==a)return(0,r.deeplyStripKey)(a,"$$ref",function(e){return"string"==typeof e&&e.indexOf("#")>-1});if(!i)if(u)i="object";else{if(!c)return;i="array"}if("object"===i){var d=(0,r.objectify)(u),h={};for(var v in d)d[v]&&d[v].deprecated||d[v]&&d[v].readOnly&&!f||d[v]&&d[v].writeOnly&&!p||(h[v]=e(d[v],n));if(!0===l)h.additionalProp1={};else if(l)for(var m=(0,r.objectify)(l),g=e(m,n),y=1;y<4;y++)h["additionalProp"+y]=g;return h}return"array"===i?Array.isArray(c.anyOf)?c.anyOf.map(function(t){return e(t,n)}):Array.isArray(c.oneOf)?c.oneOf.map(function(t){return e(t,n)}):[e(c,n)]:t.enum?t.default?t.default:(0,r.normalizeArray)(t.enum)[0]:"file"!==i?s(t):void 0},c=(t.inferSchema=function(e){return e.schema&&(e=e.schema),e.properties&&(e.type="object"),e},t.sampleXmlFromSchema=function e(t){var n,o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=(0,r.objectify)(t),a=i.type,u=i.properties,l=i.additionalProperties,c=i.items,f=i.example,p=o.includeReadOnly,d=o.includeWriteOnly,h=i.default,v={},m={},g=t.xml,y=g.name,b=g.prefix,_=g.namespace,w=i.enum,E=void 0;if(!a)if(u||l)a="object";else{if(!c)return;a="array"}(y=y||"notagname",n=(b?b+":":"")+y,_)&&(m[b?"xmlns:"+b:"xmlns"]=_);if("array"===a&&c){if(c.xml=c.xml||g||{},c.xml.name=c.xml.name||g.name,g.wrapped)return v[n]=[],Array.isArray(f)?f.forEach(function(t){c.example=t,v[n].push(e(c,o))}):Array.isArray(h)?h.forEach(function(t){c.default=t,v[n].push(e(c,o))}):v[n]=[e(c,o)],m&&v[n].push({_attr:m}),v;var x=[];return Array.isArray(f)?(f.forEach(function(t){c.example=t,x.push(e(c,o))}),x):Array.isArray(h)?(h.forEach(function(t){c.default=t,x.push(e(c,o))}),x):e(c,o)}if("object"===a){var S=(0,r.objectify)(u);for(var C in v[n]=[],f=f||{},S)if(S.hasOwnProperty(C)&&(!S[C].readOnly||p)&&(!S[C].writeOnly||d))if(S[C].xml=S[C].xml||{},S[C].xml.attribute){var k=Array.isArray(S[C].enum)&&S[C].enum[0],A=S[C].example,O=S[C].default;m[S[C].xml.name||C]=void 0!==A&&A||void 0!==f[C]&&f[C]||void 0!==O&&O||k||s(S[C])}else{S[C].xml.name=S[C].xml.name||C,void 0===S[C].example&&void 0!==f[C]&&(S[C].example=f[C]);var P=e(S[C]);Array.isArray(P)?v[n]=v[n].concat(P):v[n].push(P)}return!0===l?v[n].push({additionalProp:"Anything can be here"}):l&&v[n].push({additionalProp:s(l)}),m&&v[n].push({_attr:m}),v}return E=void 0!==f?f:void 0!==h?h:Array.isArray(w)?w[0]:s(t),v[n]=m?[{_attr:m},E]:E,v});function f(e,t){var n=c(e,t);if(n)return(0,o.default)(n,{declaration:!0,indent:"\t"})}t.memoizedCreateXMLExample=(0,i.default)(f),t.memoizedSampleFromSchema=(0,i.default)(l)},function(e,t){function n(){this._events=this._events||{},this._maxListeners=this._maxListeners||void 0}function r(e){return"function"==typeof e}function o(e){return"object"==typeof e&&null!==e}function i(e){return void 0===e}e.exports=n,n.EventEmitter=n,n.prototype._events=void 0,n.prototype._maxListeners=void 0,n.defaultMaxListeners=10,n.prototype.setMaxListeners=function(e){if("number"!=typeof e||e<0||isNaN(e))throw TypeError("n must be a positive number");return this._maxListeners=e,this},n.prototype.emit=function(e){var t,n,a,u,s,l;if(this._events||(this._events={}),"error"===e&&(!this._events.error||o(this._events.error)&&!this._events.error.length)){if((t=arguments[1])instanceof Error)throw t;var c=new Error('Uncaught, unspecified "error" event. ('+t+")");throw c.context=t,c}if(i(n=this._events[e]))return!1;if(r(n))switch(arguments.length){case 1:n.call(this);break;case 2:n.call(this,arguments[1]);break;case 3:n.call(this,arguments[1],arguments[2]);break;default:u=Array.prototype.slice.call(arguments,1),n.apply(this,u)}else if(o(n))for(u=Array.prototype.slice.call(arguments,1),a=(l=n.slice()).length,s=0;s0&&this._events[e].length>a&&(this._events[e].warned=!0,console.error("(node) warning: possible EventEmitter memory leak detected. %d listeners added. Use emitter.setMaxListeners() to increase limit.",this._events[e].length),"function"==typeof console.trace&&console.trace()),this},n.prototype.on=n.prototype.addListener,n.prototype.once=function(e,t){if(!r(t))throw TypeError("listener must be a function");var n=!1;function o(){this.removeListener(e,o),n||(n=!0,t.apply(this,arguments))}return o.listener=t,this.on(e,o),this},n.prototype.removeListener=function(e,t){var n,i,a,u;if(!r(t))throw TypeError("listener must be a function");if(!this._events||!this._events[e])return this;if(a=(n=this._events[e]).length,i=-1,n===t||r(n.listener)&&n.listener===t)delete this._events[e],this._events.removeListener&&this.emit("removeListener",e,t);else if(o(n)){for(u=a;u-- >0;)if(n[u]===t||n[u].listener&&n[u].listener===t){i=u;break}if(i<0)return this;1===n.length?(n.length=0,delete this._events[e]):n.splice(i,1),this._events.removeListener&&this.emit("removeListener",e,t)}return this},n.prototype.removeAllListeners=function(e){var t,n;if(!this._events)return this;if(!this._events.removeListener)return 0===arguments.length?this._events={}:this._events[e]&&delete this._events[e],this;if(0===arguments.length){for(t in this._events)"removeListener"!==t&&this.removeAllListeners(t);return this.removeAllListeners("removeListener"),this._events={},this}if(r(n=this._events[e]))this.removeListener(e,n);else if(n)for(;n.length;)this.removeListener(e,n[n.length-1]);return delete this._events[e],this},n.prototype.listeners=function(e){return this._events&&this._events[e]?r(this._events[e])?[this._events[e]]:this._events[e].slice():[]},n.prototype.listenerCount=function(e){if(this._events){var t=this._events[e];if(r(t))return 1;if(t)return t.length}return 0},n.listenerCount=function(e,t){return e.listenerCount(t)}},function(e,t,n){(t=e.exports=n(305)).Stream=t,t.Readable=t,t.Writable=n(196),t.Duplex=n(65),t.Transform=n(310),t.PassThrough=n(665)},function(e,t,n){"use strict";(function(t,r,o){var i=n(140);function a(e){var t=this;this.next=null,this.entry=null,this.finish=function(){!function(e,t,n){var r=e.entry;e.entry=null;for(;r;){var o=r.callback;t.pendingcb--,o(n),r=r.next}t.corkedRequestsFree?t.corkedRequestsFree.next=e:t.corkedRequestsFree=e}(t,e)}}e.exports=y;var u,s=!t.browser&&["v0.10","v0.9."].indexOf(t.version.slice(0,5))>-1?r:i.nextTick;y.WritableState=g;var l=n(107);l.inherits=n(81);var c={deprecate:n(664)},f=n(306),p=n(141).Buffer,d=o.Uint8Array||function(){};var h,v=n(307);function m(){}function g(e,t){u=u||n(65),e=e||{};var r=t instanceof u;this.objectMode=!!e.objectMode,r&&(this.objectMode=this.objectMode||!!e.writableObjectMode);var o=e.highWaterMark,l=e.writableHighWaterMark,c=this.objectMode?16:16384;this.highWaterMark=o||0===o?o:r&&(l||0===l)?l:c,this.highWaterMark=Math.floor(this.highWaterMark),this.finalCalled=!1,this.needDrain=!1,this.ending=!1,this.ended=!1,this.finished=!1,this.destroyed=!1;var f=!1===e.decodeStrings;this.decodeStrings=!f,this.defaultEncoding=e.defaultEncoding||"utf8",this.length=0,this.writing=!1,this.corked=0,this.sync=!0,this.bufferProcessing=!1,this.onwrite=function(e){!function(e,t){var n=e._writableState,r=n.sync,o=n.writecb;if(function(e){e.writing=!1,e.writecb=null,e.length-=e.writelen,e.writelen=0}(n),t)!function(e,t,n,r,o){--t.pendingcb,n?(i.nextTick(o,r),i.nextTick(S,e,t),e._writableState.errorEmitted=!0,e.emit("error",r)):(o(r),e._writableState.errorEmitted=!0,e.emit("error",r),S(e,t))}(e,n,r,t,o);else{var a=E(n);a||n.corked||n.bufferProcessing||!n.bufferedRequest||w(e,n),r?s(_,e,n,a,o):_(e,n,a,o)}}(t,e)},this.writecb=null,this.writelen=0,this.bufferedRequest=null,this.lastBufferedRequest=null,this.pendingcb=0,this.prefinished=!1,this.errorEmitted=!1,this.bufferedRequestCount=0,this.corkedRequestsFree=new a(this)}function y(e){if(u=u||n(65),!(h.call(y,this)||this instanceof u))return new y(e);this._writableState=new g(e,this),this.writable=!0,e&&("function"==typeof e.write&&(this._write=e.write),"function"==typeof e.writev&&(this._writev=e.writev),"function"==typeof e.destroy&&(this._destroy=e.destroy),"function"==typeof e.final&&(this._final=e.final)),f.call(this)}function b(e,t,n,r,o,i,a){t.writelen=r,t.writecb=a,t.writing=!0,t.sync=!0,n?e._writev(o,t.onwrite):e._write(o,i,t.onwrite),t.sync=!1}function _(e,t,n,r){n||function(e,t){0===t.length&&t.needDrain&&(t.needDrain=!1,e.emit("drain"))}(e,t),t.pendingcb--,r(),S(e,t)}function w(e,t){t.bufferProcessing=!0;var n=t.bufferedRequest;if(e._writev&&n&&n.next){var r=t.bufferedRequestCount,o=new Array(r),i=t.corkedRequestsFree;i.entry=n;for(var u=0,s=!0;n;)o[u]=n,n.isBuf||(s=!1),n=n.next,u+=1;o.allBuffers=s,b(e,t,!0,t.length,o,"",i.finish),t.pendingcb++,t.lastBufferedRequest=null,i.next?(t.corkedRequestsFree=i.next,i.next=null):t.corkedRequestsFree=new a(t),t.bufferedRequestCount=0}else{for(;n;){var l=n.chunk,c=n.encoding,f=n.callback;if(b(e,t,!1,t.objectMode?1:l.length,l,c,f),n=n.next,t.bufferedRequestCount--,t.writing)break}null===n&&(t.lastBufferedRequest=null)}t.bufferedRequest=n,t.bufferProcessing=!1}function E(e){return e.ending&&0===e.length&&null===e.bufferedRequest&&!e.finished&&!e.writing}function x(e,t){e._final(function(n){t.pendingcb--,n&&e.emit("error",n),t.prefinished=!0,e.emit("prefinish"),S(e,t)})}function S(e,t){var n=E(t);return n&&(!function(e,t){t.prefinished||t.finalCalled||("function"==typeof e._final?(t.pendingcb++,t.finalCalled=!0,i.nextTick(x,e,t)):(t.prefinished=!0,e.emit("prefinish")))}(e,t),0===t.pendingcb&&(t.finished=!0,e.emit("finish"))),n}l.inherits(y,f),g.prototype.getBuffer=function(){for(var e=this.bufferedRequest,t=[];e;)t.push(e),e=e.next;return t},function(){try{Object.defineProperty(g.prototype,"buffer",{get:c.deprecate(function(){return this.getBuffer()},"_writableState.buffer is deprecated. Use _writableState.getBuffer instead.","DEP0003")})}catch(e){}}(),"function"==typeof Symbol&&Symbol.hasInstance&&"function"==typeof Function.prototype[Symbol.hasInstance]?(h=Function.prototype[Symbol.hasInstance],Object.defineProperty(y,Symbol.hasInstance,{value:function(e){return!!h.call(this,e)||this===y&&(e&&e._writableState instanceof g)}})):h=function(e){return e instanceof this},y.prototype.pipe=function(){this.emit("error",new Error("Cannot pipe, not readable"))},y.prototype.write=function(e,t,n){var r,o=this._writableState,a=!1,u=!o.objectMode&&(r=e,p.isBuffer(r)||r instanceof d);return u&&!p.isBuffer(e)&&(e=function(e){return p.from(e)}(e)),"function"==typeof t&&(n=t,t=null),u?t="buffer":t||(t=o.defaultEncoding),"function"!=typeof n&&(n=m),o.ended?function(e,t){var n=new Error("write after end");e.emit("error",n),i.nextTick(t,n)}(this,n):(u||function(e,t,n,r){var o=!0,a=!1;return null===n?a=new TypeError("May not write null values to stream"):"string"==typeof n||void 0===n||t.objectMode||(a=new TypeError("Invalid non-string/buffer chunk")),a&&(e.emit("error",a),i.nextTick(r,a),o=!1),o}(this,o,e,n))&&(o.pendingcb++,a=function(e,t,n,r,o,i){if(!n){var a=function(e,t,n){e.objectMode||!1===e.decodeStrings||"string"!=typeof t||(t=p.from(t,n));return t}(t,r,o);r!==a&&(n=!0,o="buffer",r=a)}var u=t.objectMode?1:r.length;t.length+=u;var s=t.length-1))throw new TypeError("Unknown encoding: "+e);return this._writableState.defaultEncoding=e,this},Object.defineProperty(y.prototype,"writableHighWaterMark",{enumerable:!1,get:function(){return this._writableState.highWaterMark}}),y.prototype._write=function(e,t,n){n(new Error("_write() is not implemented"))},y.prototype._writev=null,y.prototype.end=function(e,t,n){var r=this._writableState;"function"==typeof e?(n=e,e=null,t=null):"function"==typeof t&&(n=t,t=null),null!==e&&void 0!==e&&this.write(e,t),r.corked&&(r.corked=1,this.uncork()),r.ending||r.finished||function(e,t,n){t.ending=!0,S(e,t),n&&(t.finished?i.nextTick(n):e.once("finish",n));t.ended=!0,e.writable=!1}(this,r,n)},Object.defineProperty(y.prototype,"destroyed",{get:function(){return void 0!==this._writableState&&this._writableState.destroyed},set:function(e){this._writableState&&(this._writableState.destroyed=e)}}),y.prototype.destroy=v.destroy,y.prototype._undestroy=v.undestroy,y.prototype._destroy=function(e,t){this.end(),t(e)}}).call(t,n(56),n(308).setImmediate,n(32))},function(e,t,n){"use strict";e.exports=function(e){return"function"==typeof e}},function(e,t,n){"use strict";e.exports=n(691)()?Array.from:n(692)},function(e,t,n){"use strict";var r=n(705),o=n(67),i=n(82),a=Array.prototype.indexOf,u=Object.prototype.hasOwnProperty,s=Math.abs,l=Math.floor;e.exports=function(e){var t,n,c,f;if(!r(e))return a.apply(this,arguments);for(n=o(i(this).length),c=arguments[1],t=c=isNaN(c)?0:c>=0?l(c):o(this.length)-l(s(c));t1&&void 0!==arguments[1])||arguments[1];return e=(0,r.normalizeArray)(e),{type:u,payload:{thing:e,shown:t}}},t.changeMode=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";return e=(0,r.normalizeArray)(e),{type:a,payload:{thing:e,mode:t}}};var r=n(9),o=t.UPDATE_LAYOUT="layout_update_layout",i=t.UPDATE_FILTER="layout_update_filter",a=t.UPDATE_MODE="layout_update_mode",u=t.SHOW="layout_show"},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.validateBeforeExecute=t.canExecuteScheme=t.operationScheme=t.hasHost=t.operationWithMeta=t.parameterWithMeta=t.parameterInclusionSettingFor=t.parameterWithMetaByIdentity=t.allowTryItOutFor=t.mutatedRequestFor=t.requestFor=t.responseFor=t.mutatedRequests=t.requests=t.responses=t.taggedOperations=t.operationsWithTags=t.tagDetails=t.tags=t.operationsWithRootInherited=t.schemes=t.host=t.basePath=t.definitions=t.findDefinition=t.securityDefinitions=t.security=t.produces=t.consumes=t.operations=t.paths=t.semver=t.version=t.externalDocs=t.info=t.isOAS3=t.spec=t.specJsonWithResolvedSubtrees=t.specResolvedSubtree=t.specResolved=t.specJson=t.specSource=t.specStr=t.url=t.lastError=void 0;var r,o=n(83),i=(r=o)&&r.__esModule?r:{default:r};t.getParameter=function(e,t,n,r){return t=t||[],e.getIn(["meta","paths"].concat((0,i.default)(t),["parameters"]),(0,s.fromJS)([])).find(function(e){return s.Map.isMap(e)&&e.get("name")===n&&e.get("in")===r})||(0,s.Map)()},t.parameterValues=function(e,t,n){return t=t||[],P.apply(void 0,[e].concat((0,i.default)(t))).get("parameters",(0,s.List)()).reduce(function(e,t){var r=n&&"body"===t.get("in")?t.get("value_xml"):t.get("value");return e.set(t.get("in")+"."+t.get("name"),r)},(0,s.fromJS)({}))},t.parametersIncludeIn=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";if(s.List.isList(e))return e.some(function(e){return s.Map.isMap(e)&&e.get("in")===t})},t.parametersIncludeType=T,t.contentTypeValues=function(e,t){t=t||[];var n=d(e).getIn(["paths"].concat((0,i.default)(t)),(0,s.fromJS)({})),r=e.getIn(["meta","paths"].concat((0,i.default)(t)),(0,s.fromJS)({})),o=M(e,t),a=n.get("parameters")||new s.List,u=r.get("consumes_value")?r.get("consumes_value"):T(a,"file")?"multipart/form-data":T(a,"formData")?"application/x-www-form-urlencoded":void 0;return(0,s.fromJS)({requestContentType:u,responseContentType:o})},t.operationConsumes=function(e,t){return t=t||[],d(e).getIn(["paths"].concat((0,i.default)(t),["consumes"]),(0,s.fromJS)({}))},t.currentProducesFor=M;var a=n(58),u=n(9),s=n(7);var l=["get","put","post","delete","options","head","patch","trace"],c=function(e){return e||(0,s.Map)()},f=(t.lastError=(0,a.createSelector)(c,function(e){return e.get("lastError")}),t.url=(0,a.createSelector)(c,function(e){return e.get("url")}),t.specStr=(0,a.createSelector)(c,function(e){return e.get("spec")||""}),t.specSource=(0,a.createSelector)(c,function(e){return e.get("specSource")||"not-editor"}),t.specJson=(0,a.createSelector)(c,function(e){return e.get("json",(0,s.Map)())})),p=(t.specResolved=(0,a.createSelector)(c,function(e){return e.get("resolved",(0,s.Map)())}),t.specResolvedSubtree=function(e,t){return e.getIn(["resolvedSubtrees"].concat((0,i.default)(t)),void 0)},function e(t,n){return s.Map.isMap(t)&&s.Map.isMap(n)?n.get("$$ref")?n:(0,s.OrderedMap)().mergeWith(e,t,n):n}),d=t.specJsonWithResolvedSubtrees=(0,a.createSelector)(c,function(e){return(0,s.OrderedMap)().mergeWith(p,e.get("json"),e.get("resolvedSubtrees"))}),h=t.spec=function(e){return f(e)},v=(t.isOAS3=(0,a.createSelector)(h,function(){return!1}),t.info=(0,a.createSelector)(h,function(e){return j(e&&e.get("info"))})),m=(t.externalDocs=(0,a.createSelector)(h,function(e){return j(e&&e.get("externalDocs"))}),t.version=(0,a.createSelector)(v,function(e){return e&&e.get("version")})),g=(t.semver=(0,a.createSelector)(m,function(e){return/v?([0-9]*)\.([0-9]*)\.([0-9]*)/i.exec(e).slice(1)}),t.paths=(0,a.createSelector)(d,function(e){return e.get("paths")})),y=t.operations=(0,a.createSelector)(g,function(e){if(!e||e.size<1)return(0,s.List)();var t=(0,s.List)();return e&&e.forEach?(e.forEach(function(e,n){if(!e||!e.forEach)return{};e.forEach(function(e,r){l.indexOf(r)<0||(t=t.push((0,s.fromJS)({path:n,method:r,operation:e,id:r+"-"+n})))})}),t):(0,s.List)()}),b=t.consumes=(0,a.createSelector)(h,function(e){return(0,s.Set)(e.get("consumes"))}),_=t.produces=(0,a.createSelector)(h,function(e){return(0,s.Set)(e.get("produces"))}),w=(t.security=(0,a.createSelector)(h,function(e){return e.get("security",(0,s.List)())}),t.securityDefinitions=(0,a.createSelector)(h,function(e){return e.get("securityDefinitions")}),t.findDefinition=function(e,t){var n=e.getIn(["resolvedSubtrees","definitions",t],null),r=e.getIn(["json","definitions",t],null);return n||r||null},t.definitions=(0,a.createSelector)(h,function(e){return e.get("definitions")||(0,s.Map)()}),t.basePath=(0,a.createSelector)(h,function(e){return e.get("basePath")}),t.host=(0,a.createSelector)(h,function(e){return e.get("host")}),t.schemes=(0,a.createSelector)(h,function(e){return e.get("schemes",(0,s.Map)())}),t.operationsWithRootInherited=(0,a.createSelector)(y,b,_,function(e,t,n){return e.map(function(e){return e.update("operation",function(e){if(e){if(!s.Map.isMap(e))return;return e.withMutations(function(e){return e.get("consumes")||e.update("consumes",function(e){return(0,s.Set)(e).merge(t)}),e.get("produces")||e.update("produces",function(e){return(0,s.Set)(e).merge(n)}),e})}return(0,s.Map)()})})})),E=t.tags=(0,a.createSelector)(h,function(e){return e.get("tags",(0,s.List)())}),x=t.tagDetails=function(e,t){return(E(e)||(0,s.List)()).filter(s.Map.isMap).find(function(e){return e.get("name")===t},(0,s.Map)())},S=t.operationsWithTags=(0,a.createSelector)(w,E,function(e,t){return e.reduce(function(e,t){var n=(0,s.Set)(t.getIn(["operation","tags"]));return n.count()<1?e.update("default",(0,s.List)(),function(e){return e.push(t)}):n.reduce(function(e,n){return e.update(n,(0,s.List)(),function(e){return e.push(t)})},e)},t.reduce(function(e,t){return e.set(t.get("name"),(0,s.List)())},(0,s.OrderedMap)()))}),C=(t.taggedOperations=function(e){return function(t){var n=(0,t.getConfigs)(),r=n.tagsSorter,o=n.operationsSorter;return S(e).sortBy(function(e,t){return t},function(e,t){var n="function"==typeof r?r:u.sorters.tagsSorter[r];return n?n(e,t):null}).map(function(t,n){var r="function"==typeof o?o:u.sorters.operationsSorter[o],i=r?t.sort(r):t;return(0,s.Map)({tagDetails:x(e,n),operations:i})})}},t.responses=(0,a.createSelector)(c,function(e){return e.get("responses",(0,s.Map)())})),k=t.requests=(0,a.createSelector)(c,function(e){return e.get("requests",(0,s.Map)())}),A=t.mutatedRequests=(0,a.createSelector)(c,function(e){return e.get("mutatedRequests",(0,s.Map)())}),O=(t.responseFor=function(e,t,n){return C(e).getIn([t,n],null)},t.requestFor=function(e,t,n){return k(e).getIn([t,n],null)},t.mutatedRequestFor=function(e,t,n){return A(e).getIn([t,n],null)},t.allowTryItOutFor=function(){return!0},t.parameterWithMetaByIdentity=function(e,t,n){var r=d(e).getIn(["paths"].concat((0,i.default)(t),["parameters"]),(0,s.OrderedMap)()),o=e.getIn(["meta","paths"].concat((0,i.default)(t),["parameters"]),(0,s.OrderedMap)());return r.map(function(e){var t=o.get(n.get("name")+"."+n.get("in")),r=o.get(n.get("name")+"."+n.get("in")+".hash-"+n.hashCode());return(0,s.OrderedMap)().merge(e,t,r)}).find(function(e){return e.get("in")===n.get("in")&&e.get("name")===n.get("name")},(0,s.OrderedMap)())}),P=(t.parameterInclusionSettingFor=function(e,t,n,r){var o=n+"."+r;return e.getIn(["meta","paths"].concat((0,i.default)(t),["parameter_inclusions",o]),!1)},t.parameterWithMeta=function(e,t,n,r){var o=d(e).getIn(["paths"].concat((0,i.default)(t),["parameters"]),(0,s.OrderedMap)()).find(function(e){return e.get("in")===r&&e.get("name")===n},(0,s.OrderedMap)());return O(e,t,o)},t.operationWithMeta=function(e,t,n){var r=d(e).getIn(["paths",t,n],(0,s.OrderedMap)()),o=e.getIn(["meta","paths",t,n],(0,s.OrderedMap)()),i=r.get("parameters",(0,s.List)()).map(function(r){return O(e,[t,n],r)});return(0,s.OrderedMap)().merge(r,o).set("parameters",i)});t.hasHost=(0,a.createSelector)(h,function(e){var t=e.get("host");return"string"==typeof t&&t.length>0&&"/"!==t[0]});function T(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";if(s.List.isList(e))return e.some(function(e){return s.Map.isMap(e)&&e.get("type")===t})}function M(e,t){t=t||[];var n=d(e).getIn(["paths"].concat((0,i.default)(t)),null);if(null!==n){var r=e.getIn(["meta","paths"].concat((0,i.default)(t),["produces_value"]),null),o=n.getIn(["produces",0],null);return r||o||"application/json"}}var I=t.operationScheme=function(e,t,n){var r=e.get("url").match(/^([a-z][a-z0-9+\-.]*):/),o=Array.isArray(r)?r[1]:null;return e.getIn(["scheme",t,n])||e.getIn(["scheme","_defaultScheme"])||o||""};t.canExecuteScheme=function(e,t,n){return["http","https"].indexOf(I(e,t,n))>-1},t.validateBeforeExecute=function(e,t){t=t||[];var n=!0;return e.getIn(["meta","paths"].concat((0,i.default)(t),["parameters"]),(0,s.fromJS)([])).forEach(function(e){var t=e.get("errors");t&&t.count()&&(n=!1)}),n};function j(e){return s.Map.isMap(e)?e:new s.Map}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.execute=t.executeRequest=t.logRequest=t.setMutatedRequest=t.setRequest=t.setResponse=t.updateEmptyParamInclusion=t.validateParams=t.invalidateResolvedSubtreeCache=t.updateResolvedSubtree=t.requestResolvedSubtree=t.resolveSpec=t.parseToJson=t.SET_SCHEME=t.UPDATE_RESOLVED_SUBTREE=t.UPDATE_RESOLVED=t.UPDATE_OPERATION_META_VALUE=t.CLEAR_VALIDATE_PARAMS=t.CLEAR_REQUEST=t.CLEAR_RESPONSE=t.LOG_REQUEST=t.SET_MUTATED_REQUEST=t.SET_REQUEST=t.SET_RESPONSE=t.VALIDATE_PARAMS=t.UPDATE_EMPTY_PARAM_INCLUSION=t.UPDATE_PARAM=t.UPDATE_JSON=t.UPDATE_URL=t.UPDATE_SPEC=void 0;var r=b(n(25)),o=b(n(84)),i=b(n(23)),a=b(n(41)),u=b(n(204)),s=b(n(338)),l=b(n(339)),c=b(n(45));t.updateSpec=function(e){var t=L(e).replace(/\t/g," ");if("string"==typeof e)return{type:_,payload:t}},t.updateResolved=function(e){return{type:N,payload:e}},t.updateUrl=function(e){return{type:w,payload:e}},t.updateJsonSpec=function(e){return{type:E,payload:e}},t.changeParam=function(e,t,n,r,o){return{type:x,payload:{path:e,value:r,paramName:t,paramIn:n,isXml:o}}},t.changeParamByIdentity=function(e,t,n,r){return{type:x,payload:{path:e,param:t,value:n,isXml:r}}},t.clearValidateParams=function(e){return{type:I,payload:{pathMethod:e}}},t.changeConsumesValue=function(e,t){return{type:j,payload:{path:e,value:t,key:"consumes_value"}}},t.changeProducesValue=function(e,t){return{type:j,payload:{path:e,value:t,key:"produces_value"}}},t.clearResponse=function(e,t){return{type:T,payload:{path:e,method:t}}},t.clearRequest=function(e,t){return{type:M,payload:{path:e,method:t}}},t.setScheme=function(e,t,n){return{type:D,payload:{scheme:e,path:t,method:n}}};var f=b(n(208)),p=n(7),d=b(n(210)),h=b(n(180)),v=b(n(342)),m=b(n(765)),g=b(n(767)),y=n(9);function b(e){return e&&e.__esModule?e:{default:e}}var _=t.UPDATE_SPEC="spec_update_spec",w=t.UPDATE_URL="spec_update_url",E=t.UPDATE_JSON="spec_update_json",x=t.UPDATE_PARAM="spec_update_param",S=t.UPDATE_EMPTY_PARAM_INCLUSION="spec_update_empty_param_inclusion",C=t.VALIDATE_PARAMS="spec_validate_param",k=t.SET_RESPONSE="spec_set_response",A=t.SET_REQUEST="spec_set_request",O=t.SET_MUTATED_REQUEST="spec_set_mutated_request",P=t.LOG_REQUEST="spec_log_request",T=t.CLEAR_RESPONSE="spec_clear_response",M=t.CLEAR_REQUEST="spec_clear_request",I=t.CLEAR_VALIDATE_PARAMS="spec_clear_validate_param",j=t.UPDATE_OPERATION_META_VALUE="spec_update_operation_meta_value",N=t.UPDATE_RESOLVED="spec_update_resolved",R=t.UPDATE_RESOLVED_SUBTREE="spec_update_resolved_subtree",D=t.SET_SCHEME="set_scheme",L=function(e){return(0,v.default)(e)?e:""};t.parseToJson=function(e){return function(t){var n=t.specActions,r=t.specSelectors,o=t.errActions,i=r.specStr,a=null;try{e=e||i(),o.clear({source:"parser"}),a=f.default.safeLoad(e)}catch(e){return console.error(e),o.newSpecErr({source:"parser",level:"error",message:e.reason,line:e.mark&&e.mark.line?e.mark.line+1:void 0})}return a&&"object"===(void 0===a?"undefined":(0,c.default)(a))?n.updateJsonSpec(a):{}}};var U=!1,q=(t.resolveSpec=function(e,t){return function(n){var r=n.specActions,o=n.specSelectors,i=n.errActions,a=n.fn,u=a.fetch,s=a.resolve,l=a.AST,c=void 0===l?{}:l,f=n.getConfigs;U||(console.warn("specActions.resolveSpec is deprecated since v3.10.0 and will be removed in v4.0.0; use requestResolvedSubtree instead!"),U=!0);var p=f(),d=p.modelPropertyMacro,h=p.parameterMacro,v=p.requestInterceptor,m=p.responseInterceptor;void 0===e&&(e=o.specJson()),void 0===t&&(t=o.url());var g=c.getLineNumberForPath?c.getLineNumberForPath:function(){},y=o.specStr();return s({fetch:u,spec:e,baseDoc:t,modelPropertyMacro:d,parameterMacro:h,requestInterceptor:v,responseInterceptor:m}).then(function(e){var t=e.spec,n=e.errors;if(i.clear({type:"thrown"}),Array.isArray(n)&&n.length>0){var o=n.map(function(e){return console.error(e),e.line=e.fullPath?g(y,e.fullPath):null,e.path=e.fullPath?e.fullPath.join("."):null,e.level="error",e.type="thrown",e.source="resolver",Object.defineProperty(e,"message",{enumerable:!0,value:e.message}),e});i.newThrownErrBatch(o)}return r.updateResolved(t)})}},[]),F=(0,m.default)((0,l.default)(s.default.mark(function e(){var t,n,r,o,i,a,c,f,d,h,v,m,y,b,_,w,E;return s.default.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:if(t=q.system){e.next=4;break}return console.error("debResolveSubtrees: don't have a system to operate on, aborting."),e.abrupt("return");case 4:if(n=t.errActions,r=t.errSelectors,o=t.fn,i=o.resolveSubtree,a=o.AST,c=void 0===a?{}:a,f=t.specSelectors,d=t.specActions,i){e.next=8;break}return console.error("Error: Swagger-Client did not provide a `resolveSubtree` method, doing nothing."),e.abrupt("return");case 8:return h=c.getLineNumberForPath?c.getLineNumberForPath:function(){},v=f.specStr(),m=t.getConfigs(),y=m.modelPropertyMacro,b=m.parameterMacro,_=m.requestInterceptor,w=m.responseInterceptor,e.prev=11,e.next=14,q.reduce(function(){var e=(0,l.default)(s.default.mark(function e(t,o){var a,u,l,c,p,d,m;return s.default.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:return e.next=2,t;case 2:return a=e.sent,u=a.resultMap,l=a.specWithCurrentSubtrees,e.next=7,i(l,o,{baseDoc:f.url(),modelPropertyMacro:y,parameterMacro:b,requestInterceptor:_,responseInterceptor:w});case 7:return c=e.sent,p=c.errors,d=c.spec,r.allErrors().size&&n.clear({type:"thrown"}),Array.isArray(p)&&p.length>0&&(m=p.map(function(e){return e.line=e.fullPath?h(v,e.fullPath):null,e.path=e.fullPath?e.fullPath.join("."):null,e.level="error",e.type="thrown",e.source="resolver",Object.defineProperty(e,"message",{enumerable:!0,value:e.message}),e}),n.newThrownErrBatch(m)),(0,g.default)(u,o,d),(0,g.default)(l,o,d),e.abrupt("return",{resultMap:u,specWithCurrentSubtrees:l});case 15:case"end":return e.stop()}},e,void 0)}));return function(t,n){return e.apply(this,arguments)}}(),u.default.resolve({resultMap:(f.specResolvedSubtree([])||(0,p.Map)()).toJS(),specWithCurrentSubtrees:f.specJson().toJS()}));case 14:E=e.sent,delete q.system,q=[],e.next=22;break;case 19:e.prev=19,e.t0=e.catch(11),console.error(e.t0);case 22:d.updateResolvedSubtree([],E.resultMap);case 23:case"end":return e.stop()}},e,void 0,[[11,19]])})),35);t.requestResolvedSubtree=function(e){return function(t){q.push(e),q.system=t,F()}};t.updateResolvedSubtree=function(e,t){return{type:R,payload:{path:e,value:t}}},t.invalidateResolvedSubtreeCache=function(){return{type:R,payload:{path:[],value:(0,p.Map)()}}},t.validateParams=function(e,t){return{type:C,payload:{pathMethod:e,isOAS3:t}}},t.updateEmptyParamInclusion=function(e,t,n,r){return{type:S,payload:{pathMethod:e,paramName:t,paramIn:n,includeEmptyValue:r}}};t.setResponse=function(e,t,n){return{payload:{path:e,method:t,res:n},type:k}},t.setRequest=function(e,t,n){return{payload:{path:e,method:t,req:n},type:A}},t.setMutatedRequest=function(e,t,n){return{payload:{path:e,method:t,req:n},type:O}},t.logRequest=function(e){return{payload:e,type:P}},t.executeRequest=function(e){return function(t){var n=t.fn,r=t.specActions,o=t.specSelectors,u=t.getConfigs,s=t.oas3Selectors,l=e.pathName,c=e.method,f=e.operation,p=u(),v=p.requestInterceptor,m=p.responseInterceptor,g=f.toJS();if(g&&g.parameters&&g.parameters.length&&g.parameters.filter(function(e){return e&&!0===e.allowEmptyValue}).forEach(function(t){if(o.parameterInclusionSettingFor([l,c],t.name,t.in)){e.parameters=e.parameters||{};var n=e.parameters[t.name];(!n||n&&0===n.size)&&(e.parameters[t.name]="")}}),e.contextUrl=(0,d.default)(o.url()).toString(),g&&g.operationId?e.operationId=g.operationId:g&&l&&c&&(e.operationId=n.opId(g,l,c)),o.isOAS3()){var b=l+":"+c;e.server=s.selectedServer(b)||s.selectedServer();var _=s.serverVariables({server:e.server,namespace:b}).toJS(),w=s.serverVariables({server:e.server}).toJS();e.serverVariables=(0,a.default)(_).length?_:w,e.requestContentType=s.requestContentType(l,c),e.responseContentType=s.responseContentType(l,c)||"*/*";var E=s.requestBodyValue(l,c);(0,y.isJSONObject)(E)?e.requestBody=JSON.parse(E):E&&E.toJS?e.requestBody=E.toJS():e.requestBody=E}var x=(0,i.default)({},e);x=n.buildRequest(x),r.setRequest(e.pathName,e.method,x);e.requestInterceptor=function(t){var n=v.apply(this,[t]),o=(0,i.default)({},n);return r.setMutatedRequest(e.pathName,e.method,o),n},e.responseInterceptor=m;var S=Date.now();return n.execute(e).then(function(t){t.duration=Date.now()-S,r.setResponse(e.pathName,e.method,t)}).catch(function(t){return r.setResponse(e.pathName,e.method,{error:!0,err:(0,h.default)(t)})})}};t.execute=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=e.path,n=e.method,i=(0,o.default)(e,["path","method"]);return function(e){var o=e.fn.fetch,a=e.specSelectors,u=e.specActions,s=a.specJsonWithResolvedSubtrees().toJS(),l=a.operationScheme(t,n),c=a.contentTypeValues([t,n]).toJS(),f=c.requestContentType,p=c.responseContentType,d=/xml/i.test(f),h=a.parameterValues([t,n],d).toJS();return u.executeRequest((0,r.default)({},i,{fetch:o,spec:s,pathName:t,method:n,parameters:h,requestContentType:f,scheme:l,responseContentType:p}))}}},function(e,t,n){e.exports={default:n(733),__esModule:!0}},function(e,t){e.exports=function(e,t,n,r){if(!(e instanceof t)||void 0!==r&&r in e)throw TypeError(n+": incorrect invocation!");return e}},function(e,t,n){"use strict";var r=n(95);e.exports.f=function(e){return new function(e){var t,n;this.promise=new e(function(e,r){if(void 0!==t||void 0!==n)throw TypeError("Bad Promise constructor");t=e,n=r}),this.resolve=r(t),this.reject=r(n)}(e)}},function(e,t,n){var r=n(50);e.exports=function(e,t,n){for(var o in t)n&&e[o]?e[o]=t[o]:r(e,o,t[o]);return e}},function(e,t,n){"use strict";var r=n(743);e.exports=r},function(e,t,n){"use strict";var r=n(86);e.exports=new r({explicit:[n(746),n(747),n(748)]})},function(e,t,n){"use strict";(function(t){var r=n(763),o=n(764),i=/^([a-z][a-z0-9.+-]*:)?(\/\/)?([\S\s]*)/i,a=/^[A-Za-z][A-Za-z0-9+-.]*:\/\//,u=[["#","hash"],["?","query"],function(e){return e.replace("\\","/")},["/","pathname"],["@","auth",1],[NaN,"host",void 0,1,1],[/:(\d+)$/,"port",void 0,1],[NaN,"hostname",void 0,1,1]],s={hash:1,query:1};function l(e){var n,r=t&&t.location||{},o={},i=typeof(e=e||r);if("blob:"===e.protocol)o=new f(unescape(e.pathname),{});else if("string"===i)for(n in o=new f(e,{}),s)delete o[n];else if("object"===i){for(n in e)n in s||(o[n]=e[n]);void 0===o.slashes&&(o.slashes=a.test(e.href))}return o}function c(e){var t=i.exec(e);return{protocol:t[1]?t[1].toLowerCase():"",slashes:!!t[2],rest:t[3]}}function f(e,t,n){if(!(this instanceof f))return new f(e,t,n);var i,a,s,p,d,h,v=u.slice(),m=typeof t,g=this,y=0;for("object"!==m&&"string"!==m&&(n=t,t=null),n&&"function"!=typeof n&&(n=o.parse),t=l(t),i=!(a=c(e||"")).protocol&&!a.slashes,g.slashes=a.slashes||i&&t.slashes,g.protocol=a.protocol||t.protocol||"",e=a.rest,a.slashes||(v[3]=[/(.*)/,"pathname"]);y-1||r("96",e),!l.plugins[n]){t.extractEvents||r("97",e),l.plugins[n]=t;var a=t.eventTypes;for(var s in a)u(a[s],t,s)||r("98",s,e)}}}function u(e,t,n){l.eventNameDispatchConfigs.hasOwnProperty(n)&&r("99",n),l.eventNameDispatchConfigs[n]=e;var o=e.phasedRegistrationNames;if(o){for(var i in o){if(o.hasOwnProperty(i))s(o[i],t,n)}return!0}return!!e.registrationName&&(s(e.registrationName,t,n),!0)}function s(e,t,n){l.registrationNameModules[e]&&r("100",e),l.registrationNameModules[e]=t,l.registrationNameDependencies[e]=t.eventTypes[n].dependencies}var l={plugins:[],eventNameDispatchConfigs:{},registrationNameModules:{},registrationNameDependencies:{},possibleRegistrationNames:null,injectEventPluginOrder:function(e){o&&r("101"),o=Array.prototype.slice.call(e),a()},injectEventPluginsByName:function(e){var t=!1;for(var n in e)if(e.hasOwnProperty(n)){var o=e[n];i.hasOwnProperty(n)&&i[n]===o||(i[n]&&r("102",n),i[n]=o,t=!0)}t&&a()},getPluginModuleForEvent:function(e){var t=e.dispatchConfig;if(t.registrationName)return l.registrationNameModules[t.registrationName]||null;if(void 0!==t.phasedRegistrationNames){var n=t.phasedRegistrationNames;for(var r in n)if(n.hasOwnProperty(r)){var o=l.registrationNameModules[n[r]];if(o)return o}}return null},_resetEventPlugins:function(){for(var e in o=null,i)i.hasOwnProperty(e)&&delete i[e];l.plugins.length=0;var t=l.eventNameDispatchConfigs;for(var n in t)t.hasOwnProperty(n)&&delete t[n];var r=l.registrationNameModules;for(var a in r)r.hasOwnProperty(a)&&delete r[a]}};e.exports=l},function(e,t,n){"use strict";var r,o,i=n(10),a=n(213);n(8),n(12);function u(e,t,n,r){var o=e.type||"unknown-event";e.currentTarget=s.getNodeFromInstance(r),t?a.invokeGuardedCallbackWithCatch(o,n,e):a.invokeGuardedCallback(o,n,e),e.currentTarget=null}var s={isEndish:function(e){return"topMouseUp"===e||"topTouchEnd"===e||"topTouchCancel"===e},isMoveish:function(e){return"topMouseMove"===e||"topTouchMove"===e},isStartish:function(e){return"topMouseDown"===e||"topTouchStart"===e},executeDirectDispatch:function(e){var t=e._dispatchListeners,n=e._dispatchInstances;Array.isArray(t)&&i("103"),e.currentTarget=t?s.getNodeFromInstance(n):null;var r=t?t(e):null;return e.currentTarget=null,e._dispatchListeners=null,e._dispatchInstances=null,r},executeDispatchesInOrder:function(e,t){var n=e._dispatchListeners,r=e._dispatchInstances;if(Array.isArray(n))for(var o=0;o0&&r.length<20?n+" (keys: "+r.join(", ")+")":n}function s(e,t){var n=o.get(e);return n||null}var l={isMounted:function(e){var t=o.get(e);return!!t&&!!t._renderedComponent},enqueueCallback:function(e,t,n){l.validateCallback(t,n);var r=s(e);if(!r)return null;r._pendingCallbacks?r._pendingCallbacks.push(t):r._pendingCallbacks=[t],a(r)},enqueueCallbackInternal:function(e,t){e._pendingCallbacks?e._pendingCallbacks.push(t):e._pendingCallbacks=[t],a(e)},enqueueForceUpdate:function(e){var t=s(e);t&&(t._pendingForceUpdate=!0,a(t))},enqueueReplaceState:function(e,t,n){var r=s(e);r&&(r._pendingStateQueue=[t],r._pendingReplaceState=!0,void 0!==n&&null!==n&&(l.validateCallback(n,"replaceState"),r._pendingCallbacks?r._pendingCallbacks.push(n):r._pendingCallbacks=[n]),a(r))},enqueueSetState:function(e,t){var n=s(e);n&&((n._pendingStateQueue||(n._pendingStateQueue=[])).push(t),a(n))},enqueueElementInternal:function(e,t,n){e._pendingElement=t,e._context=n,a(e)},validateCallback:function(e,t){e&&"function"!=typeof e&&r("122",t,u(e))}};e.exports=l},function(e,t,n){"use strict";n(13);var r=n(42),o=(n(12),r);e.exports=o},function(e,t,n){"use strict";e.exports=function(e){var t,n=e.keyCode;return"charCode"in e?0===(t=e.charCode)&&13===n&&(t=13):t=n,t>=32||13===t?t:0}},function(e,t,n){var r=n(62),o=n(229),i=n(47),a="[object Object]",u=Function.prototype,s=Object.prototype,l=u.toString,c=s.hasOwnProperty,f=l.call(Object);e.exports=function(e){if(!i(e)||r(e)!=a)return!1;var t=o(e);if(null===t)return!0;var n=c.call(t,"constructor")&&t.constructor;return"function"==typeof n&&n instanceof n&&l.call(n)==f}},function(e,t,n){var r=n(297)(Object.getPrototypeOf,Object);e.exports=r},function(e,t,n){var r=n(291);e.exports=function(e){var t=new e.constructor(e.byteLength);return new r(t).set(new r(e)),t}},function(e,t){var n=this&&this.__extends||function(e,t){for(var n in t)t.hasOwnProperty(n)&&(e[n]=t[n]);function r(){this.constructor=e}e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)},r=Object.prototype.hasOwnProperty; +/*! + * https://github.com/Starcounter-Jack/JSON-Patch + * (c) 2017 Joachim Wester + * MIT license + */function o(e,t){return r.call(e,t)}function i(e){if(Array.isArray(e)){for(var t=new Array(e.length),n=0;n=48&&t<=57))return!1;n++}return!0},t.escapePathComponent=a,t.unescapePathComponent=function(e){return e.replace(/~1/g,"/").replace(/~0/g,"~")},t._getPathRecursive=u,t.getPath=function(e,t){if(e===t)return"/";var n=u(e,t);if(""===n)throw new Error("Object not found in root");return"/"+n},t.hasUndefined=function e(t){if(void 0===t)return!0;if(t)if(Array.isArray(t)){for(var n=0,r=t.length;nw;w++)if((p||w in y)&&(m=b(v=y[w],w,g),e))if(n)E[w]=m;else if(m)switch(e){case 3:return!0;case 5:return v;case 6:return w;case 2:E.push(v)}else if(c)return!1;return f?-1:l||c?c:E}}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.authorizeRequest=t.authorizeAccessCodeWithBasicAuthentication=t.authorizeAccessCodeWithFormParams=t.authorizeApplication=t.authorizePassword=t.preAuthorizeImplicit=t.CONFIGURE_AUTH=t.VALIDATE=t.AUTHORIZE_OAUTH2=t.PRE_AUTHORIZE_OAUTH2=t.LOGOUT=t.AUTHORIZE=t.SHOW_AUTH_POPUP=void 0;var r=l(n(45)),o=l(n(23)),i=l(n(40));t.showDefinitions=function(e){return{type:c,payload:e}},t.authorize=function(e){return{type:f,payload:e}},t.logout=function(e){return{type:p,payload:e}},t.authorizeOauth2=function(e){return{type:d,payload:e}},t.configureAuth=function(e){return{type:h,payload:e}};var a=l(n(210)),u=l(n(33)),s=n(9);function l(e){return e&&e.__esModule?e:{default:e}}var c=t.SHOW_AUTH_POPUP="show_popup",f=t.AUTHORIZE="authorize",p=t.LOGOUT="logout",d=(t.PRE_AUTHORIZE_OAUTH2="pre_authorize_oauth2",t.AUTHORIZE_OAUTH2="authorize_oauth2"),h=(t.VALIDATE="validate",t.CONFIGURE_AUTH="configure_auth");t.preAuthorizeImplicit=function(e){return function(t){var n=t.authActions,r=t.errActions,o=e.auth,a=e.token,s=e.isValid,l=o.schema,c=o.name,f=l.get("flow");delete u.default.swaggerUIRedirectOauth2,"accessCode"===f||s||r.newAuthErr({authId:c,source:"auth",level:"warning",message:"Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"}),a.error?r.newAuthErr({authId:c,source:"auth",level:"error",message:(0,i.default)(a)}):n.authorizeOauth2({auth:o,token:a})}};t.authorizePassword=function(e){return function(t){var n=t.authActions,r=e.schema,i=e.name,a=e.username,u=e.password,l=e.passwordType,c=e.clientId,f=e.clientSecret,p={grant_type:"password",scope:e.scopes.join(" ")},d={},h={};return"basic"===l?h.Authorization="Basic "+(0,s.btoa)(a+":"+u):((0,o.default)(p,{username:a},{password:u}),"query"===l?(c&&(d.client_id=c),f&&(d.client_secret=f)):h.Authorization="Basic "+(0,s.btoa)(c+":"+f)),n.authorizeRequest({body:(0,s.buildFormData)(p),url:r.get("tokenUrl"),name:i,headers:h,query:d,auth:e})}},t.authorizeApplication=function(e){return function(t){var n=t.authActions,r=e.schema,o=e.scopes,i=e.name,a=e.clientId,u=e.clientSecret,l={Authorization:"Basic "+(0,s.btoa)(a+":"+u)},c={grant_type:"client_credentials",scope:o.join(" ")};return n.authorizeRequest({body:(0,s.buildFormData)(c),name:i,url:r.get("tokenUrl"),auth:e,headers:l})}},t.authorizeAccessCodeWithFormParams=function(e){var t=e.auth,n=e.redirectUrl;return function(e){var r=e.authActions,o=t.schema,i=t.name,a=t.clientId,u=t.clientSecret,l={grant_type:"authorization_code",code:t.code,client_id:a,client_secret:u,redirect_uri:n};return r.authorizeRequest({body:(0,s.buildFormData)(l),name:i,url:o.get("tokenUrl"),auth:t})}},t.authorizeAccessCodeWithBasicAuthentication=function(e){var t=e.auth,n=e.redirectUrl;return function(e){var r=e.authActions,o=t.schema,i=t.name,a=t.clientId,u=t.clientSecret,l={Authorization:"Basic "+(0,s.btoa)(a+":"+u)},c={grant_type:"authorization_code",code:t.code,client_id:a,redirect_uri:n};return r.authorizeRequest({body:(0,s.buildFormData)(c),name:i,url:o.get("tokenUrl"),auth:t,headers:l})}},t.authorizeRequest=function(e){return function(t){var n=t.fn,u=t.getConfigs,s=t.authActions,l=t.errActions,c=t.oas3Selectors,f=t.specSelectors,p=t.authSelectors,d=e.body,h=e.query,v=void 0===h?{}:h,m=e.headers,g=void 0===m?{}:m,y=e.name,b=e.url,_=e.auth,w=(p.getConfigs()||{}).additionalQueryStringParams,E=void 0;E=f.isOAS3()?(0,a.default)(b,c.selectedServer(),!0):(0,a.default)(b,f.url(),!0),"object"===(void 0===w?"undefined":(0,r.default)(w))&&(E.query=(0,o.default)({},E.query,w));var x=E.toString(),S=(0,o.default)({Accept:"application/json, text/plain, */*","Content-Type":"application/x-www-form-urlencoded"},g);n.fetch({url:x,method:"post",headers:S,query:v,body:d,requestInterceptor:u().requestInterceptor,responseInterceptor:u().responseInterceptor}).then(function(e){var t=JSON.parse(e.data),n=t&&(t.error||""),r=t&&(t.parseError||"");e.ok?n||r?l.newAuthErr({authId:y,level:"error",source:"auth",message:(0,i.default)(t)}):s.authorizeOauth2({auth:_,token:t}):l.newAuthErr({authId:y,level:"error",source:"auth",message:e.statusText})}).catch(function(e){var t=new Error(e).message;if(e.response&&e.response.data){var n=e.response.data;try{var r="string"==typeof n?JSON.parse(n):n;r.error&&(t+=", error: "+r.error),r.error_description&&(t+=", description: "+r.error_description)}catch(e){}}l.newAuthErr({authId:y,level:"error",source:"auth",message:t})})}}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.parseYamlConfig=void 0;var r,o=n(208),i=(r=o)&&r.__esModule?r:{default:r};t.parseYamlConfig=function(e,t){try{return i.default.safeLoad(e)}catch(e){return t&&t.errActions.newThrownErr(new Error(e)),{}}}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.loaded=t.TOGGLE_CONFIGS=t.UPDATE_CONFIGS=void 0;var r,o=n(22),i=(r=o)&&r.__esModule?r:{default:r};t.update=function(e,t){return{type:a,payload:(0,i.default)({},e,t)}},t.toggle=function(e){return{type:u,payload:e}};var a=t.UPDATE_CONFIGS="configs_update",u=t.TOGGLE_CONFIGS="configs_toggle";t.loaded=function(){return function(){}}},function(e,t,n){"use strict";function r(e,t,n,r,o){this.src=e,this.env=r,this.options=n,this.parser=t,this.tokens=o,this.pos=0,this.posMax=this.src.length,this.level=0,this.pending="",this.pendingLevel=0,this.cache=[],this.isInLabel=!1,this.linkLevel=0,this.linkContent="",this.labelUnmatchedScopes=0}r.prototype.pushPending=function(){this.tokens.push({type:"text",content:this.pending,level:this.pendingLevel}),this.pending=""},r.prototype.push=function(e){this.pending&&this.pushPending(),this.tokens.push(e),this.pendingLevel=this.level},r.prototype.cacheSet=function(e,t){for(var n=this.cache.length;n<=e;n++)this.cache.push(0);this.cache[e]=t},r.prototype.cacheGet=function(e){return es;)r(u,n=t[s++])&&(~i(l,n)||l.push(n));return l}},function(e,t,n){var r=n(19).document;e.exports=r&&r.documentElement},function(e,t,n){var r=n(52),o=n(72),i=n(161)("IE_PROTO"),a=Object.prototype;e.exports=Object.getPrototypeOf||function(e){return e=o(e),r(e,i)?e[i]:"function"==typeof e.constructor&&e instanceof e.constructor?e.constructor.prototype:e instanceof Object?a:null}},function(e,t,n){var r=n(53),o=n(29),i=o["__core-js_shared__"]||(o["__core-js_shared__"]={});(e.exports=function(e,t){return i[e]||(i[e]=void 0!==t?t:{})})("versions",[]).push({version:r.version,mode:n(167)?"pure":"global",copyright:"© 2018 Denis Pushkarev (zloirock.ru)"})},function(e,t){e.exports=function(e,t){return{enumerable:!(1&e),configurable:!(2&e),writable:!(4&e),value:t}}},function(e,t,n){"use strict";var r=n(246)(!0);n(247)(String,"String",function(e){this._t=String(e),this._i=0},function(){var e,t=this._t,n=this._i;return n>=t.length?{value:void 0,done:!0}:(e=r(t,n),this._i+=e.length,{value:e,done:!1})})},function(e,t,n){var r=n(119),o=n(54);e.exports=function(e){return function(t,n){var i,a,u=String(o(t)),s=r(n),l=u.length;return s<0||s>=l?e?"":void 0:(i=u.charCodeAt(s))<55296||i>56319||s+1===l||(a=u.charCodeAt(s+1))<56320||a>57343?e?u.charAt(s):i:e?u.slice(s,s+2):a-56320+(i-55296<<10)+65536}}},function(e,t,n){"use strict";var r=n(167),o=n(30),i=n(73),a=n(59),u=n(103),s=n(461),l=n(171),c=n(467),f=n(17)("iterator"),p=!([].keys&&"next"in[].keys()),d=function(){return this};e.exports=function(e,t,n,h,v,m,g){s(n,t,h);var y,b,_,w=function(e){if(!p&&e in C)return C[e];switch(e){case"keys":case"values":return function(){return new n(this,e)}}return function(){return new n(this,e)}},E=t+" Iterator",x="values"==v,S=!1,C=e.prototype,k=C[f]||C["@@iterator"]||v&&C[v],A=k||w(v),O=v?x?w("entries"):A:void 0,P="Array"==t&&C.entries||k;if(P&&(_=c(P.call(new e)))!==Object.prototype&&_.next&&(l(_,E,!0),r||"function"==typeof _[f]||a(_,f,d)),x&&k&&"values"!==k.name&&(S=!0,A=function(){return k.call(this)}),r&&!g||!p&&!S&&C[f]||a(C,f,A),u[t]=A,u[E]=d,v)if(y={values:x?A:w("values"),keys:m?A:w("keys"),entries:O},g)for(b in y)b in C||i(C,b,y[b]);else o(o.P+o.F*(p||S),t,y);return y}},function(e,t,n){var r=n(464),o=n(250);e.exports=Object.keys||function(e){return r(e,o)}},function(e,t,n){var r=n(119),o=Math.max,i=Math.min;e.exports=function(e,t){return(e=r(e))<0?o(e+t,0):i(e,t)}},function(e,t){e.exports="constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf".split(",")},function(e,t,n){var r=n(29).document;e.exports=r&&r.documentElement},function(e,t,n){var r=n(60),o=n(121),i=n(17)("species");e.exports=function(e,t){var n,a=r(e).constructor;return void 0===a||void 0==(n=r(a)[i])?t:o(n)}},function(e,t,n){var r,o,i,a=n(120),u=n(479),s=n(251),l=n(169),c=n(29),f=c.process,p=c.setImmediate,d=c.clearImmediate,h=c.MessageChannel,v=c.Dispatch,m=0,g={},y=function(){var e=+this;if(g.hasOwnProperty(e)){var t=g[e];delete g[e],t()}},b=function(e){y.call(e.data)};p&&d||(p=function(e){for(var t=[],n=1;arguments.length>n;)t.push(arguments[n++]);return g[++m]=function(){u("function"==typeof e?e:Function(e),t)},r(m),m},d=function(e){delete g[e]},"process"==n(100)(f)?r=function(e){f.nextTick(a(y,e,1))}:v&&v.now?r=function(e){v.now(a(y,e,1))}:h?(i=(o=new h).port2,o.port1.onmessage=b,r=a(i.postMessage,i,1)):c.addEventListener&&"function"==typeof postMessage&&!c.importScripts?(r=function(e){c.postMessage(e+"","*")},c.addEventListener("message",b,!1)):r="onreadystatechange"in l("script")?function(e){s.appendChild(l("script")).onreadystatechange=function(){s.removeChild(this),y.call(e)}}:function(e){setTimeout(a(y,e,1),0)}),e.exports={set:p,clear:d}},function(e,t){e.exports=function(e){try{return{e:!1,v:e()}}catch(e){return{e:!0,v:e}}}},function(e,t,n){var r=n(60),o=n(74),i=n(172);e.exports=function(e,t){if(r(e),o(t)&&t.constructor===e)return t;var n=i.f(e);return(0,n.resolve)(t),n.promise}},function(e,t,n){var r=n(74),o=n(100),i=n(17)("match");e.exports=function(e){var t;return r(e)&&(void 0!==(t=e[i])?!!t:"RegExp"==o(e))}},function(e,t,n){var r=n(21),o=n(14),i=n(51);e.exports=function(e,t){var n=(o.Object||{})[e]||Object[e],a={};a[e]=t(n),r(r.S+r.F*i(function(){n(1)}),"Object",a)}},function(e,t,n){var r=n(93);e.exports=Array.isArray||function(e){return"Array"==r(e)}},function(e,t,n){var r=n(240),o=n(163).concat("length","prototype");t.f=Object.getOwnPropertyNames||function(e){return r(e,o)}},function(e,t,n){var r=n(125),o=n(96),i=n(71),a=n(157),u=n(52),s=n(239),l=Object.getOwnPropertyDescriptor;t.f=n(44)?l:function(e,t){if(e=i(e),t=a(t,!0),s)try{return l(e,t)}catch(e){}if(u(e,t))return o(!r.f.call(e,t),e[t])}},function(e,t){var n={}.toString;e.exports=Array.isArray||function(e){return"[object Array]"==n.call(e)}},function(e,t,n){e.exports={default:n(532),__esModule:!0}},function(e,t,n){"use strict";var r=n(97),o=n(177),i=n(125),a=n(72),u=n(154),s=Object.assign;e.exports=!s||n(51)(function(){var e={},t={},n=Symbol(),r="abcdefghijklmnopqrst";return e[n]=7,r.split("").forEach(function(e){t[e]=e}),7!=s({},e)[n]||Object.keys(s({},t)).join("")!=r})?function(e,t){for(var n=a(e),s=arguments.length,l=1,c=o.f,f=i.f;s>l;)for(var p,d=u(arguments[l++]),h=c?r(d).concat(c(d)):r(d),v=h.length,m=0;v>m;)f.call(d,p=h[m++])&&(n[p]=d[p]);return n}:s},function(e,t,n){"use strict";var r=n(105),o=n(13),i=n(265),a=(n(266),n(126));n(8),n(536);function u(e,t,n){this.props=e,this.context=t,this.refs=a,this.updater=n||i}function s(e,t,n){this.props=e,this.context=t,this.refs=a,this.updater=n||i}function l(){}u.prototype.isReactComponent={},u.prototype.setState=function(e,t){"object"!=typeof e&&"function"!=typeof e&&null!=e&&r("85"),this.updater.enqueueSetState(this,e),t&&this.updater.enqueueCallback(this,t,"setState")},u.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this),e&&this.updater.enqueueCallback(this,e,"forceUpdate")},l.prototype=u.prototype,s.prototype=new l,s.prototype.constructor=s,o(s.prototype,u.prototype),s.prototype.isPureReactComponent=!0,e.exports={Component:u,PureComponent:s}},function(e,t,n){"use strict";n(12);var r={isMounted:function(e){return!1},enqueueCallback:function(e,t){},enqueueForceUpdate:function(e){},enqueueReplaceState:function(e,t){},enqueueSetState:function(e,t){}};e.exports=r},function(e,t,n){"use strict";var r=!1;e.exports=r},function(e,t,n){"use strict";var r="function"==typeof Symbol&&Symbol.for&&Symbol.for("react.element")||60103;e.exports=r},function(e,t,n){"use strict";var r=n(544);e.exports=function(e){return r(e,!1)}},function(e,t,n){"use strict";e.exports="SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED"},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=n(271),o=n(561),i=n(562),a=n(563),u=n(275);n(274);n.d(t,"createStore",function(){return r.b}),n.d(t,"combineReducers",function(){return o.a}),n.d(t,"bindActionCreators",function(){return i.a}),n.d(t,"applyMiddleware",function(){return a.a}),n.d(t,"compose",function(){return u.a})},function(e,t,n){"use strict";n.d(t,"a",function(){return i}),t.b=function e(t,n,a){var u;"function"==typeof n&&void 0===a&&(a=n,n=void 0);if(void 0!==a){if("function"!=typeof a)throw new Error("Expected the enhancer to be a function.");return a(e)(t,n)}if("function"!=typeof t)throw new Error("Expected the reducer to be a function.");var s=t;var l=n;var c=[];var f=c;var p=!1;function d(){f===c&&(f=c.slice())}function h(){return l}function v(e){if("function"!=typeof e)throw new Error("Expected listener to be a function.");var t=!0;return d(),f.push(e),function(){if(t){t=!1,d();var n=f.indexOf(e);f.splice(n,1)}}}function m(e){if(!Object(r.a)(e))throw new Error("Actions must be plain objects. Use custom middleware for async actions.");if(void 0===e.type)throw new Error('Actions may not have an undefined "type" property. Have you misspelled a constant?');if(p)throw new Error("Reducers may not dispatch actions.");try{p=!0,l=s(l,e)}finally{p=!1}for(var t=c=f,n=0;no?0:o+t),(n=n>o?o:n)<0&&(n+=o),o=t>n?0:n-t>>>0,t>>>=0;for(var i=Array(o);++rp))return!1;var h=c.get(e);if(h&&c.get(t))return h==t;var v=-1,m=!0,g=n&u?new r:void 0;for(c.set(e,t),c.set(t,e);++v0?("string"==typeof t||a.objectMode||Object.getPrototypeOf(t)===l.prototype||(t=function(e){return l.from(e)}(t)),r?a.endEmitted?e.emit("error",new Error("stream.unshift() after end event")):w(e,a,t,!0):a.ended?e.emit("error",new Error("stream.push() after EOF")):(a.reading=!1,a.decoder&&!n?(t=a.decoder.write(t),a.objectMode||0!==t.length?w(e,a,t,!1):k(e,a)):w(e,a,t,!1))):r||(a.reading=!1));return function(e){return!e.ended&&(e.needReadable||e.lengtht.highWaterMark&&(t.highWaterMark=function(e){return e>=E?e=E:(e--,e|=e>>>1,e|=e>>>2,e|=e>>>4,e|=e>>>8,e|=e>>>16,e++),e}(e)),e<=t.length?e:t.ended?t.length:(t.needReadable=!0,0))}function S(e){var t=e._readableState;t.needReadable=!1,t.emittedReadable||(d("emitReadable",t.flowing),t.emittedReadable=!0,t.sync?o.nextTick(C,e):C(e))}function C(e){d("emit readable"),e.emit("readable"),T(e)}function k(e,t){t.readingMore||(t.readingMore=!0,o.nextTick(A,e,t))}function A(e,t){for(var n=t.length;!t.reading&&!t.flowing&&!t.ended&&t.length=t.length?(n=t.decoder?t.buffer.join(""):1===t.buffer.length?t.buffer.head.data:t.buffer.concat(t.length),t.buffer.clear()):n=function(e,t,n){var r;ei.length?i.length:e;if(a===i.length?o+=i:o+=i.slice(0,e),0===(e-=a)){a===i.length?(++r,n.next?t.head=n.next:t.head=t.tail=null):(t.head=n,n.data=i.slice(a));break}++r}return t.length-=r,o}(e,t):function(e,t){var n=l.allocUnsafe(e),r=t.head,o=1;r.data.copy(n),e-=r.data.length;for(;r=r.next;){var i=r.data,a=e>i.length?i.length:e;if(i.copy(n,n.length-e,0,a),0===(e-=a)){a===i.length?(++o,r.next?t.head=r.next:t.head=t.tail=null):(t.head=r,r.data=i.slice(a));break}++o}return t.length-=o,n}(e,t);return r}(e,t.buffer,t.decoder),n);var n}function I(e){var t=e._readableState;if(t.length>0)throw new Error('"endReadable()" called on non-empty stream');t.endEmitted||(t.ended=!0,o.nextTick(j,t,e))}function j(e,t){e.endEmitted||0!==e.length||(e.endEmitted=!0,t.readable=!1,t.emit("end"))}function N(e,t){for(var n=0,r=e.length;n=t.highWaterMark||t.ended))return d("read: emitReadable",t.length,t.ended),0===t.length&&t.ended?I(this):S(this),null;if(0===(e=x(e,t))&&t.ended)return 0===t.length&&I(this),null;var r,o=t.needReadable;return d("need readable",o),(0===t.length||t.length-e0?M(e,t):null)?(t.needReadable=!0,e=0):t.length-=e,0===t.length&&(t.ended||(t.needReadable=!0),n!==e&&t.ended&&I(this)),null!==r&&this.emit("data",r),r},b.prototype._read=function(e){this.emit("error",new Error("_read() is not implemented"))},b.prototype.pipe=function(e,t){var n=this,i=this._readableState;switch(i.pipesCount){case 0:i.pipes=e;break;case 1:i.pipes=[i.pipes,e];break;default:i.pipes.push(e)}i.pipesCount+=1,d("pipe count=%d opts=%j",i.pipesCount,t);var s=(!t||!1!==t.end)&&e!==r.stdout&&e!==r.stderr?c:b;function l(t,r){d("onunpipe"),t===n&&r&&!1===r.hasUnpiped&&(r.hasUnpiped=!0,d("cleanup"),e.removeListener("close",g),e.removeListener("finish",y),e.removeListener("drain",f),e.removeListener("error",m),e.removeListener("unpipe",l),n.removeListener("end",c),n.removeListener("end",b),n.removeListener("data",v),p=!0,!i.awaitDrain||e._writableState&&!e._writableState.needDrain||f())}function c(){d("onend"),e.end()}i.endEmitted?o.nextTick(s):n.once("end",s),e.on("unpipe",l);var f=function(e){return function(){var t=e._readableState;d("pipeOnDrain",t.awaitDrain),t.awaitDrain&&t.awaitDrain--,0===t.awaitDrain&&u(e,"data")&&(t.flowing=!0,T(e))}}(n);e.on("drain",f);var p=!1;var h=!1;function v(t){d("ondata"),h=!1,!1!==e.write(t)||h||((1===i.pipesCount&&i.pipes===e||i.pipesCount>1&&-1!==N(i.pipes,e))&&!p&&(d("false write response, pause",n._readableState.awaitDrain),n._readableState.awaitDrain++,h=!0),n.pause())}function m(t){d("onerror",t),b(),e.removeListener("error",m),0===u(e,"error")&&e.emit("error",t)}function g(){e.removeListener("finish",y),b()}function y(){d("onfinish"),e.removeListener("close",g),b()}function b(){d("unpipe"),n.unpipe(e)}return n.on("data",v),function(e,t,n){if("function"==typeof e.prependListener)return e.prependListener(t,n);e._events&&e._events[t]?a(e._events[t])?e._events[t].unshift(n):e._events[t]=[n,e._events[t]]:e.on(t,n)}(e,"error",m),e.once("close",g),e.once("finish",y),e.emit("pipe",n),i.flowing||(d("pipe resume"),n.resume()),e},b.prototype.unpipe=function(e){var t=this._readableState,n={hasUnpiped:!1};if(0===t.pipesCount)return this;if(1===t.pipesCount)return e&&e!==t.pipes?this:(e||(e=t.pipes),t.pipes=null,t.pipesCount=0,t.flowing=!1,e&&e.emit("unpipe",this,n),this);if(!e){var r=t.pipes,o=t.pipesCount;t.pipes=null,t.pipesCount=0,t.flowing=!1;for(var i=0;i=0&&(e._idleTimeoutId=setTimeout(function(){e._onTimeout&&e._onTimeout()},t))},n(663),t.setImmediate="undefined"!=typeof self&&self.setImmediate||void 0!==e&&e.setImmediate||this&&this.setImmediate,t.clearImmediate="undefined"!=typeof self&&self.clearImmediate||void 0!==e&&e.clearImmediate||this&&this.clearImmediate}).call(t,n(32))},function(e,t,n){"use strict";var r=n(141).Buffer,o=r.isEncoding||function(e){switch((e=""+e)&&e.toLowerCase()){case"hex":case"utf8":case"utf-8":case"ascii":case"binary":case"base64":case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":case"raw":return!0;default:return!1}};function i(e){var t;switch(this.encoding=function(e){var t=function(e){if(!e)return"utf8";for(var t;;)switch(e){case"utf8":case"utf-8":return"utf8";case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return"utf16le";case"latin1":case"binary":return"latin1";case"base64":case"ascii":case"hex":return e;default:if(t)return;e=(""+e).toLowerCase(),t=!0}}(e);if("string"!=typeof t&&(r.isEncoding===o||!o(e)))throw new Error("Unknown encoding: "+e);return t||e}(e),this.encoding){case"utf16le":this.text=s,this.end=l,t=4;break;case"utf8":this.fillLast=u,t=4;break;case"base64":this.text=c,this.end=f,t=3;break;default:return this.write=p,void(this.end=d)}this.lastNeed=0,this.lastTotal=0,this.lastChar=r.allocUnsafe(t)}function a(e){return e<=127?0:e>>5==6?2:e>>4==14?3:e>>3==30?4:e>>6==2?-1:-2}function u(e){var t=this.lastTotal-this.lastNeed,n=function(e,t,n){if(128!=(192&t[0]))return e.lastNeed=0,"�";if(e.lastNeed>1&&t.length>1){if(128!=(192&t[1]))return e.lastNeed=1,"�";if(e.lastNeed>2&&t.length>2&&128!=(192&t[2]))return e.lastNeed=2,"�"}}(this,e);return void 0!==n?n:this.lastNeed<=e.length?(e.copy(this.lastChar,t,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal)):(e.copy(this.lastChar,t,0,e.length),void(this.lastNeed-=e.length))}function s(e,t){if((e.length-t)%2==0){var n=e.toString("utf16le",t);if(n){var r=n.charCodeAt(n.length-1);if(r>=55296&&r<=56319)return this.lastNeed=2,this.lastTotal=4,this.lastChar[0]=e[e.length-2],this.lastChar[1]=e[e.length-1],n.slice(0,-1)}return n}return this.lastNeed=1,this.lastTotal=2,this.lastChar[0]=e[e.length-1],e.toString("utf16le",t,e.length-1)}function l(e){var t=e&&e.length?this.write(e):"";if(this.lastNeed){var n=this.lastTotal-this.lastNeed;return t+this.lastChar.toString("utf16le",0,n)}return t}function c(e,t){var n=(e.length-t)%3;return 0===n?e.toString("base64",t):(this.lastNeed=3-n,this.lastTotal=3,1===n?this.lastChar[0]=e[e.length-1]:(this.lastChar[0]=e[e.length-2],this.lastChar[1]=e[e.length-1]),e.toString("base64",t,e.length-n))}function f(e){var t=e&&e.length?this.write(e):"";return this.lastNeed?t+this.lastChar.toString("base64",0,3-this.lastNeed):t}function p(e){return e.toString(this.encoding)}function d(e){return e&&e.length?this.write(e):""}t.StringDecoder=i,i.prototype.write=function(e){if(0===e.length)return"";var t,n;if(this.lastNeed){if(void 0===(t=this.fillLast(e)))return"";n=this.lastNeed,this.lastNeed=0}else n=0;return n=0)return o>0&&(e.lastNeed=o-1),o;if(--r=0)return o>0&&(e.lastNeed=o-2),o;if(--r=0)return o>0&&(2===o?o=0:e.lastNeed=o-3),o;return 0}(this,e,t);if(!this.lastNeed)return e.toString("utf8",t);this.lastTotal=n;var r=e.length-(n-this.lastNeed);return e.copy(this.lastChar,0,r),e.toString("utf8",t,r)},i.prototype.fillLast=function(e){if(this.lastNeed<=e.length)return e.copy(this.lastChar,this.lastTotal-this.lastNeed,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal);e.copy(this.lastChar,this.lastTotal-this.lastNeed,0,e.length),this.lastNeed-=e.length}},function(e,t,n){"use strict";e.exports=i;var r=n(65),o=n(107);function i(e){if(!(this instanceof i))return new i(e);r.call(this,e),this._transformState={afterTransform:function(e,t){var n=this._transformState;n.transforming=!1;var r=n.writecb;if(!r)return this.emit("error",new Error("write callback called multiple times"));n.writechunk=null,n.writecb=null,null!=t&&this.push(t),r(e);var o=this._readableState;o.reading=!1,(o.needReadable||o.length=0?n&&o?o-1:o:1:!1!==e&&r(e)}},function(e,t,n){"use strict";e.exports=n(679)()?Object.assign:n(680)},function(e,t,n){"use strict";var r,o,i,a,u,s=n(67),l=function(e,t){return t};try{Object.defineProperty(l,"length",{configurable:!0,writable:!1,enumerable:!1,value:1})}catch(e){}1===l.length?(r={configurable:!0,writable:!1,enumerable:!1},o=Object.defineProperty,e.exports=function(e,t){return t=s(t),e.length===t?e:(r.value=t,o(e,"length",r))}):(a=n(316),u=[],i=function(e){var t,n=0;if(u[e])return u[e];for(t=[];e--;)t.push("a"+(++n).toString(36));return new Function("fn","return function ("+t.join(", ")+") { return fn.apply(this, arguments); };")},e.exports=function(e,t){var n;if(t=s(t),e.length===t)return e;n=i(t)(e);try{a(n,e)}catch(e){}return n})},function(e,t,n){"use strict";var r=n(82),o=Object.defineProperty,i=Object.getOwnPropertyDescriptor,a=Object.getOwnPropertyNames,u=Object.getOwnPropertySymbols;e.exports=function(e,t){var n,s=Object(r(t));if(e=Object(r(e)),a(s).forEach(function(r){try{o(e,r,i(t,r))}catch(e){n=e}}),"function"==typeof u&&u(s).forEach(function(r){try{o(e,r,i(t,r))}catch(e){n=e}}),void 0!==n)throw n;return e}},function(e,t,n){"use strict";var r=n(57),o=n(142),i=Function.prototype.call;e.exports=function(e,t){var n={},a=arguments[2];return r(t),o(e,function(e,r,o,u){n[r]=i.call(t,a,e,r,o,u)}),n}},function(e,t){e.exports=function(e){return!!e&&("object"==typeof e||"function"==typeof e)&&"function"==typeof e.then}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(e){return{statePlugins:{err:{reducers:(0,i.default)(e),actions:a,selectors:u}}}};var r,o=n(320),i=(r=o)&&r.__esModule?r:{default:r},a=s(n(127)),u=s(n(325));function s(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n]);return t.default=e,t}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=s(n(22)),o=s(n(23));t.default=function(e){var t;return t={},(0,r.default)(t,i.NEW_THROWN_ERR,function(t,n){var r=n.payload,i=(0,o.default)(l,r,{type:"thrown"});return t.update("errors",function(e){return(e||(0,a.List)()).push((0,a.fromJS)(i))}).update("errors",function(t){return(0,u.default)(t,e.getSystem())})}),(0,r.default)(t,i.NEW_THROWN_ERR_BATCH,function(t,n){var r=n.payload;return r=r.map(function(e){return(0,a.fromJS)((0,o.default)(l,e,{type:"thrown"}))}),t.update("errors",function(e){return(e||(0,a.List)()).concat((0,a.fromJS)(r))}).update("errors",function(t){return(0,u.default)(t,e.getSystem())})}),(0,r.default)(t,i.NEW_SPEC_ERR,function(t,n){var r=n.payload,o=(0,a.fromJS)(r);return o=o.set("type","spec"),t.update("errors",function(e){return(e||(0,a.List)()).push((0,a.fromJS)(o)).sortBy(function(e){return e.get("line")})}).update("errors",function(t){return(0,u.default)(t,e.getSystem())})}),(0,r.default)(t,i.NEW_SPEC_ERR_BATCH,function(t,n){var r=n.payload;return r=r.map(function(e){return(0,a.fromJS)((0,o.default)(l,e,{type:"spec"}))}),t.update("errors",function(e){return(e||(0,a.List)()).concat((0,a.fromJS)(r))}).update("errors",function(t){return(0,u.default)(t,e.getSystem())})}),(0,r.default)(t,i.NEW_AUTH_ERR,function(t,n){var r=n.payload,i=(0,a.fromJS)((0,o.default)({},r));return i=i.set("type","auth"),t.update("errors",function(e){return(e||(0,a.List)()).push((0,a.fromJS)(i))}).update("errors",function(t){return(0,u.default)(t,e.getSystem())})}),(0,r.default)(t,i.CLEAR,function(e,t){var n=t.payload;if(!n||!e.get("errors"))return e;var r=e.get("errors").filter(function(e){return e.keySeq().every(function(t){var r=e.get(t),o=n[t];return!o||r!==o})});return e.merge({errors:r})}),(0,r.default)(t,i.CLEAR_BY,function(e,t){var n=t.payload;if(!n||"function"!=typeof n)return e;var r=e.get("errors").filter(function(e){return n(e)});return e.merge({errors:r})}),t};var i=n(127),a=n(7),u=s(n(321));function s(e){return e&&e.__esModule?e:{default:e}}var l={line:0,level:"error",message:"Unknown error"}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(e,t){var n={jsSpec:t.specSelectors.specJson().toJS()};return(0,i.default)(u,function(e,t){try{var r=t.transform(e,n);return r.filter(function(e){return!!e})}catch(t){return console.error("Transformer error:",t),e}},e).filter(function(e){return!!e}).map(function(e){return!e.get("line")&&e.get("path"),e})};var r,o=n(727),i=(r=o)&&r.__esModule?r:{default:r};function a(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n]);return t.default=e,t}var u=[a(n(322)),a(n(323)),a(n(324))]},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.transform=function(e){return e.map(function(e){var t=e.get("message").indexOf("is not of a type(s)");if(t>-1){var n=e.get("message").slice(t+"is not of a type(s)".length).split(",");return e.set("message",e.get("message").slice(0,t)+function(e){return e.reduce(function(e,t,n,r){return n===r.length-1&&r.length>1?e+"or "+t:r[n+1]&&r.length>2?e+t+", ":r[n+1]?e+t+" ":e+t},"should be a")}(n))}return e})}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.transform=function(e,t){t.jsSpec;return e};var r,o=n(138);(r=o)&&r.__esModule,n(7)},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.transform=function(e){return e.map(function(e){return e.set("message",(t=e.get("message"),n="instance.",t.replace(new RegExp(n,"g"),"")));var t,n})}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.lastError=t.allErrors=void 0;var r=n(7),o=n(58),i=t.allErrors=(0,o.createSelector)(function(e){return e},function(e){return e.get("errors",(0,r.List)())});t.lastError=(0,o.createSelector)(i,function(e){return e.last()})},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(){return{statePlugins:{layout:{reducers:i.default,actions:a,selectors:u}}}};var r,o=n(327),i=(r=o)&&r.__esModule?r:{default:r},a=s(n(201)),u=s(n(328));function s(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n]);return t.default=e,t}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r,o,i=n(22),a=(r=i)&&r.__esModule?r:{default:r},u=n(7),s=n(201);t.default=(o={},(0,a.default)(o,s.UPDATE_LAYOUT,function(e,t){return e.set("layout",t.payload)}),(0,a.default)(o,s.UPDATE_FILTER,function(e,t){return e.set("filter",t.payload)}),(0,a.default)(o,s.SHOW,function(e,t){var n=t.payload.shown,r=(0,u.fromJS)(t.payload.thing);return e.update("shown",(0,u.fromJS)({}),function(e){return e.set(r,n)})}),(0,a.default)(o,s.UPDATE_MODE,function(e,t){var n=t.payload.thing,r=t.payload.mode;return e.setIn(["modes"].concat(n),(r||"")+"")}),o)},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.showSummary=t.whatMode=t.isShown=t.currentFilter=t.current=void 0;var r,o=n(83),i=(r=o)&&r.__esModule?r:{default:r},a=n(58),u=n(9),s=n(7);t.current=function(e){return e.get("layout")},t.currentFilter=function(e){return e.get("filter")};var l=t.isShown=function(e,t,n){return t=(0,u.normalizeArray)(t),e.get("shown",(0,s.fromJS)({})).get((0,s.fromJS)(t),n)};t.whatMode=function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"";return t=(0,u.normalizeArray)(t),e.getIn(["modes"].concat((0,i.default)(t)),n)},t.showSummary=(0,a.createSelector)(function(e){return e},function(e){return!l(e,"editor")})},function(e,t,n){var r=n(35);e.exports=function(e,t,n,o){try{return o?t(r(n)[0],n[1]):t(n)}catch(t){var i=e.return;throw void 0!==i&&r(i.call(e)),t}}},function(e,t,n){var r=n(70),o=n(20)("iterator"),i=Array.prototype;e.exports=function(e){return void 0!==e&&(r.Array===e||i[o]===e)}},function(e,t,n){var r=n(20)("iterator"),o=!1;try{var i=[7][r]();i.return=function(){o=!0},Array.from(i,function(){throw 2})}catch(e){}e.exports=function(e,t){if(!t&&!o)return!1;var n=!1;try{var i=[7],a=i[r]();a.next=function(){return{done:n=!0}},i[r]=function(){return a},e(i)}catch(e){}return n}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(){return{statePlugins:{spec:{wrapActions:s,reducers:i.default,actions:a,selectors:u}}}};var r,o=n(333),i=(r=o)&&r.__esModule?r:{default:r},a=l(n(203)),u=l(n(202)),s=l(n(346));function l(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n]);return t.default=e,t}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r,o=p(n(22)),i=p(n(23)),a=p(n(83)),u=n(7),s=n(9),l=p(n(33)),c=n(202),f=n(203);function p(e){return e&&e.__esModule?e:{default:e}}t.default=(r={},(0,o.default)(r,f.UPDATE_SPEC,function(e,t){return"string"==typeof t.payload?e.set("spec",t.payload):e}),(0,o.default)(r,f.UPDATE_URL,function(e,t){return e.set("url",t.payload+"")}),(0,o.default)(r,f.UPDATE_JSON,function(e,t){return e.set("json",(0,s.fromJSOrdered)(t.payload))}),(0,o.default)(r,f.UPDATE_RESOLVED,function(e,t){return e.setIn(["resolved"],(0,s.fromJSOrdered)(t.payload))}),(0,o.default)(r,f.UPDATE_RESOLVED_SUBTREE,function(e,t){var n=t.payload,r=n.value,o=n.path;return e.setIn(["resolvedSubtrees"].concat((0,a.default)(o)),(0,s.fromJSOrdered)(r))}),(0,o.default)(r,f.UPDATE_PARAM,function(e,t){var n=t.payload,r=n.path,o=n.paramName,i=n.paramIn,u=n.param,s=n.value,l=n.isXml,c=void 0;c=u&&u.hashCode&&!i&&!o?u.get("name")+"."+u.get("in")+".hash-"+u.hashCode():o+"."+i;var f=l?"value_xml":"value";return e.setIn(["meta","paths"].concat((0,a.default)(r),["parameters",c,f]),s)}),(0,o.default)(r,f.UPDATE_EMPTY_PARAM_INCLUSION,function(e,t){var n=t.payload,r=n.pathMethod,o=n.paramName,i=n.paramIn,u=n.includeEmptyValue;if(!o||!i)return console.warn("Warning: UPDATE_EMPTY_PARAM_INCLUSION could not generate a paramKey."),e;var s=o+"."+i;return e.setIn(["meta","paths"].concat((0,a.default)(r),["parameter_inclusions",s]),u)}),(0,o.default)(r,f.VALIDATE_PARAMS,function(e,t){var n=t.payload,r=n.pathMethod,o=n.isOAS3,i=e.getIn(["meta","paths"].concat((0,a.default)(r)),(0,u.fromJS)({})),l=/xml/i.test(i.get("consumes_value")),f=c.operationWithMeta.apply(void 0,[e].concat((0,a.default)(r)));return e.updateIn(["meta","paths"].concat((0,a.default)(r),["parameters"]),(0,u.fromJS)({}),function(e){return f.get("parameters",(0,u.List)()).reduce(function(e,t){var n=(0,s.validateParam)(t,l,o);return e.setIn([t.get("name")+"."+t.get("in"),"errors"],(0,u.fromJS)(n))},e)})}),(0,o.default)(r,f.CLEAR_VALIDATE_PARAMS,function(e,t){var n=t.payload.pathMethod;return e.updateIn(["meta","paths"].concat((0,a.default)(n),["parameters"]),(0,u.fromJS)([]),function(e){return e.map(function(e){return e.set("errors",(0,u.fromJS)([]))})})}),(0,o.default)(r,f.SET_RESPONSE,function(e,t){var n=t.payload,r=n.res,o=n.path,a=n.method,u=void 0;(u=r.error?(0,i.default)({error:!0,name:r.err.name,message:r.err.message,statusCode:r.err.statusCode},r.err.response):r).headers=u.headers||{};var c=e.setIn(["responses",o,a],(0,s.fromJSOrdered)(u));return l.default.Blob&&r.data instanceof l.default.Blob&&(c=c.setIn(["responses",o,a,"text"],r.data)),c}),(0,o.default)(r,f.SET_REQUEST,function(e,t){var n=t.payload,r=n.req,o=n.path,i=n.method;return e.setIn(["requests",o,i],(0,s.fromJSOrdered)(r))}),(0,o.default)(r,f.SET_MUTATED_REQUEST,function(e,t){var n=t.payload,r=n.req,o=n.path,i=n.method;return e.setIn(["mutatedRequests",o,i],(0,s.fromJSOrdered)(r))}),(0,o.default)(r,f.UPDATE_OPERATION_META_VALUE,function(e,t){var n=t.payload,r=n.path,o=n.value,i=n.key,s=["paths"].concat((0,a.default)(r)),l=["meta","paths"].concat((0,a.default)(r));return e.getIn(["json"].concat((0,a.default)(s)))||e.getIn(["resolved"].concat((0,a.default)(s)))||e.getIn(["resolvedSubtrees"].concat((0,a.default)(s)))?e.setIn([].concat((0,a.default)(l),[i]),(0,u.fromJS)(o)):e}),(0,o.default)(r,f.CLEAR_RESPONSE,function(e,t){var n=t.payload,r=n.path,o=n.method;return e.deleteIn(["responses",r,o])}),(0,o.default)(r,f.CLEAR_REQUEST,function(e,t){var n=t.payload,r=n.path,o=n.method;return e.deleteIn(["requests",r,o])}),(0,o.default)(r,f.SET_SCHEME,function(e,t){var n=t.payload,r=n.scheme,o=n.path,i=n.method;return o&&i?e.setIn(["scheme",o,i],r):o||i?void 0:e.setIn(["scheme","_defaultScheme"],r)}),r)},function(e,t,n){var r=n(35),o=n(95),i=n(20)("species");e.exports=function(e,t){var n,a=r(e).constructor;return void 0===a||void 0==(n=r(a)[i])?t:o(n)}},function(e,t,n){var r,o,i,a=n(49),u=n(735),s=n(241),l=n(156),c=n(19),f=c.process,p=c.setImmediate,d=c.clearImmediate,h=c.MessageChannel,v=c.Dispatch,m=0,g={},y=function(){var e=+this;if(g.hasOwnProperty(e)){var t=g[e];delete g[e],t()}},b=function(e){y.call(e.data)};p&&d||(p=function(e){for(var t=[],n=1;arguments.length>n;)t.push(arguments[n++]);return g[++m]=function(){u("function"==typeof e?e:Function(e),t)},r(m),m},d=function(e){delete g[e]},"process"==n(93)(f)?r=function(e){f.nextTick(a(y,e,1))}:v&&v.now?r=function(e){v.now(a(y,e,1))}:h?(i=(o=new h).port2,o.port1.onmessage=b,r=a(i.postMessage,i,1)):c.addEventListener&&"function"==typeof postMessage&&!c.importScripts?(r=function(e){c.postMessage(e+"","*")},c.addEventListener("message",b,!1)):r="onreadystatechange"in l("script")?function(e){s.appendChild(l("script")).onreadystatechange=function(){s.removeChild(this),y.call(e)}}:function(e){setTimeout(a(y,e,1),0)}),e.exports={set:p,clear:d}},function(e,t){e.exports=function(e){try{return{e:!1,v:e()}}catch(e){return{e:!0,v:e}}}},function(e,t,n){var r=n(35),o=n(28),i=n(206);e.exports=function(e,t){if(r(e),o(t)&&t.constructor===e)return t;var n=i.f(e);return(0,n.resolve)(t),n.promise}},function(e,t,n){e.exports=n(741)},function(e,t,n){"use strict";t.__esModule=!0;var r,o=n(204),i=(r=o)&&r.__esModule?r:{default:r};t.default=function(e){return function(){var t=e.apply(this,arguments);return new i.default(function(e,n){return function r(o,a){try{var u=t[o](a),s=u.value}catch(e){return void n(e)}if(!u.done)return i.default.resolve(s).then(function(e){r("next",e)},function(e){r("throw",e)});e(s)}("next")})}}},function(e,t,n){"use strict";var r=n(86);e.exports=new r({include:[n(341)]})},function(e,t,n){"use strict";var r=n(86);e.exports=new r({include:[n(209)],implicit:[n(749),n(750),n(751),n(752)]})},function(e,t,n){var r=n(62),o=n(24),i=n(47),a="[object String]";e.exports=function(e){return"string"==typeof e||!o(e)&&i(e)&&r(e)==a}},function(e,t,n){var r=n(146),o=n(79),i=n(135),a=n(37),u=n(80);e.exports=function(e,t,n,s){if(!a(e))return e;for(var l=-1,c=(t=o(t,e)).length,f=c-1,p=e;null!=p&&++l.":"function"==typeof t?" Instead of passing a class like Foo, pass React.createElement(Foo) or .":null!=t&&void 0!==t.props?" This may be caused by unintentionally loading two independent copies of React.":"");var i,u=a.createElement(D,{child:t});if(e){var s=p.get(e);i=s._processChildContext(s._context)}else i=g;var l=N(n);if(l){var c=l._currentElement.props.child;if(_(c,t)){var f=l._renderedComponent.getPublicInstance(),d=o&&function(){o.call(f)};return L._updateRootComponent(l,u,i,n,d),f}L.unmountComponentAtNode(n)}var h=A(n),m=h&&!!O(h),y=I(n),b=m&&!l&&!y,w=L._renderNewRootComponent(u,n,b,i)._renderedComponent.getPublicInstance();return o&&o.call(w),w},render:function(e,t,n){return L._renderSubtreeIntoContainer(null,e,t,n)},unmountComponentAtNode:function(e){j(e)||r("40");var t=N(e);if(!t){I(e),1===e.nodeType&&e.hasAttribute(E);return!1}return delete k[t._instance.rootID],m.batchedUpdates(M,t,e,!1),!0},_mountImageIntoNode:function(e,t,n,i,a){if(j(t)||r("41"),i){var u=A(t);if(d.canReuseMarkup(e,u))return void s.precacheNode(n,u);var l=u.getAttribute(d.CHECKSUM_ATTR_NAME);u.removeAttribute(d.CHECKSUM_ATTR_NAME);var c=u.outerHTML;u.setAttribute(d.CHECKSUM_ATTR_NAME,l);var f=e,p=function(e,t){for(var n=Math.min(e.length,t.length),r=0;r1?r-1:0),a=1;a=o&&(t=console)[e].apply(t,i)}return i.warn=i.bind(null,"warn"),i.error=i.bind(null,"error"),i.info=i.bind(null,"info"),i.debug=i.bind(null,"debug"),{rootInjects:{log:i}}}},function(e,t,n){"use strict";var r,o=n(387),i=(r=o)&&r.__esModule?r:{default:r};e.exports=function(e){var t=e.configs;return{fn:{fetch:i.default.makeHttp(t.preFetch,t.postFetch),buildRequest:i.default.buildRequest,execute:i.default.execute,resolve:i.default.resolve,resolveSubtree:i.default.resolveSubtree,serializeRes:i.default.serializeRes,opId:i.default.helpers.opId}}}},function(e,t,n){e.exports=function(e){function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}var n={};return t.m=e,t.c=n,t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:r})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="",t(t.s=23)}([function(e,t){e.exports=n(41)},function(e,t){e.exports=n(45)},function(e,t){e.exports=n(23)},function(e,t){e.exports=n(25)},function(e,t){e.exports=n(338)},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function o(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"",r=(arguments.length>3&&void 0!==arguments[3]?arguments[3]:{}).v2OperationIdCompatibilityMode;return e&&"object"===(void 0===e?"undefined":(0,c.default)(e))?(e.operationId||"").replace(/\s/g,"").length?h(e.operationId):i(t,n,{v2OperationIdCompatibilityMode:r}):null}function i(e,t){if((arguments.length>2&&void 0!==arguments[2]?arguments[2]:{}).v2OperationIdCompatibilityMode){var n=(t.toLowerCase()+"_"+e).replace(/[\s!@#$%^&*()_+=[{\]};:<>|.\/?,\\'""-]/g,"_");return(n=n||e.substring(1)+"_"+t).replace(/((_){2,})/g,"_").replace(/^(_)*/g,"").replace(/([_])*$/g,"")}return""+d(t)+h(e)}function a(e,t){return d(t)+"-"+e}function u(e,t){return s(e,t,!0)||null}function s(e,t,n){if(!e||"object"!==(void 0===e?"undefined":(0,c.default)(e))||!e.paths||"object"!==(0,c.default)(e.paths))return null;var r=e.paths;for(var o in r)for(var i in r[o])if("PARAMETERS"!==i.toUpperCase()){var a=r[o][i];if(a&&"object"===(void 0===a?"undefined":(0,c.default)(a))){var u={spec:e,pathName:o,method:i.toUpperCase(),operation:a},s=t(u);if(n&&s)return u}}}Object.defineProperty(t,"__esModule",{value:!0});var l=r(n(18)),c=r(n(1));t.isOAS3=function(e){var t=e.openapi;return!!t&&(0,p.default)(t,"3")},t.isSwagger2=function(e){var t=e.swagger;return!!t&&(0,p.default)(t,"2")},t.opId=o,t.idFromPathMethod=i,t.legacyIdFromPathMethod=a,t.getOperationRaw=function(e,t){return e&&e.paths?u(e,function(e){var n=e.pathName,r=e.method,i=e.operation;if(!i||"object"!==(void 0===i?"undefined":(0,c.default)(i)))return!1;var u=i.operationId;return[o(i,n,r),a(n,r),u].some(function(e){return e&&e===t})}):null},t.findOperation=u,t.eachOperation=s,t.normalizeSwagger=function(e){var t=e.spec,n=t.paths,r={};if(!n||t.$$normalized)return e;for(var i in n){var a=n[i];if((0,f.default)(a)){var u=a.parameters;for(var s in a)!function(e){var n=a[e];if(!(0,f.default)(n))return"continue";var s=o(n,i,e);if(s){r[s]?r[s].push(n):r[s]=[n];var c=r[s];if(c.length>1)c.forEach(function(e,t){e.__originalOperationId=e.__originalOperationId||e.operationId,e.operationId=""+s+(t+1)});else if(void 0!==n.operationId){var p=c[0];p.__originalOperationId=p.__originalOperationId||n.operationId,p.operationId=s}}if("parameters"!==e){var d=[],h={};for(var v in t)"produces"!==v&&"consumes"!==v&&"security"!==v||(h[v]=t[v],d.push(h));if(u&&(h.parameters=u,d.push(h)),d.length){var m=!0,g=!1,y=void 0;try{for(var b,_=(0,l.default)(d);!(m=(b=_.next()).done);m=!0){var w=b.value;for(var E in w)if(n[E]){if("parameters"===E){var x=!0,S=!1,C=void 0;try{for(var k,A=(0,l.default)(w[E]);!(x=(k=A.next()).done);x=!0)!function(){var e=k.value;n[E].some(function(t){return t.name&&t.name===e.name||t.$ref&&t.$ref===e.$ref||t.$$ref&&t.$$ref===e.$$ref||t===e})||n[E].push(e)}()}catch(e){S=!0,C=e}finally{try{!x&&A.return&&A.return()}finally{if(S)throw C}}}}else n[E]=w[E]}}catch(e){g=!0,y=e}finally{try{!m&&_.return&&_.return()}finally{if(g)throw y}}}}}(s)}}return t.$$normalized=!0,e};var f=r(n(47)),p=r(n(14)),d=function(e){return String.prototype.toLowerCase.call(e)},h=function(e){return e.replace(/[^\w]/gi,"_")}},function(e,t){e.exports=n(894)},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function o(e,t){var n=(arguments.length>2&&void 0!==arguments[2]?arguments[2]:{}).loadSpec,r=void 0!==n&&n,o={ok:e.ok,url:e.url||t,status:e.status,statusText:e.statusText,headers:i(e.headers)},a=o.headers["content-type"],u=r||_(a);return(u?e.text:e.blob||e.buffer).call(e).then(function(e){if(o.text=e,o.data=e,u)try{var t=function(e,t){return"application/json"===t?JSON.parse(e):g.default.safeLoad(e)}(e,a);o.body=t,o.obj=t}catch(e){o.parseError=e}return o})}function i(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t={};return"function"==typeof e.forEach?(e.forEach(function(e,n){void 0!==t[n]?(t[n]=Array.isArray(t[n])?t[n]:[t[n]],t[n].push(e)):t[n]=e}),t):t}function a(e){return"undefined"!=typeof File?e instanceof File:null!==e&&"object"===(void 0===e?"undefined":(0,h.default)(e))&&"function"==typeof e.pipe}function u(e,t){var n=e.collectionFormat,r=e.allowEmptyValue,o="object"===(void 0===e?"undefined":(0,h.default)(e))?e.value:e;if(void 0===o&&r)return"";if(a(o)||"boolean"==typeof o)return o;var i=encodeURIComponent;return t&&(i=(0,y.default)(o)?function(e){return e}:function(e){return(0,p.default)(e)}),"object"!==(void 0===o?"undefined":(0,h.default)(o))||Array.isArray(o)?Array.isArray(o)?Array.isArray(o)&&!n?o.map(i).join(","):"multi"===n?o.map(i):o.map(i).join({csv:",",ssv:"%20",tsv:"%09",pipes:"|"}[n]):i(o):""}function s(e){var t=(0,f.default)(e).reduce(function(t,n){var r=e[n],o=!!r.skipEncoding,i=o?n:encodeURIComponent(n),a=function(e){return e&&"object"===(void 0===e?"undefined":(0,h.default)(e))}(r)&&!Array.isArray(r);return t[i]=u(a?r:{value:r},o),t},{});return m.default.stringify(t,{encode:!1,indices:!1})||""}function l(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=e.url,r=void 0===t?"":t,o=e.query,i=e.form;if(i){var l=(0,f.default)(i).some(function(e){return a(i[e].value)}),p=e.headers["content-type"]||e.headers["Content-Type"];if(l||/multipart\/form-data/i.test(p)){var d=n(30);e.body=new d,(0,f.default)(i).forEach(function(t){e.body.append(t,u(i[t],!0))})}else e.body=s(i);delete e.form}if(o){var h=r.split("?"),v=(0,c.default)(h,2),g=v[0],y=v[1],b="";if(y){var _=m.default.parse(y);(0,f.default)(o).forEach(function(e){return delete _[e]}),b=m.default.stringify(_,{encode:!0})}var w=function(){for(var e=arguments.length,t=Array(e),n=0;n1&&void 0!==arguments[1]?arguments[1]:{};return d.default.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:if("object"===(void 0===t?"undefined":(0,h.default)(t))&&(t=(a=t).url),a.headers=a.headers||{},b.mergeInQueryOrForm(a),!a.requestInterceptor){e.next=10;break}return e.next=6,a.requestInterceptor(a);case 6:if(e.t0=e.sent,e.t0){e.next=9;break}e.t0=a;case 9:a=e.t0;case 10:return n=a.headers["content-type"]||a.headers["Content-Type"],/multipart\/form-data/i.test(n)&&(delete a.headers["content-type"],delete a.headers["Content-Type"]),r=void 0,e.prev=13,e.next=16,(a.userFetch||fetch)(a.url,a);case 16:return r=e.sent,e.next=19,b.serializeRes(r,t,a);case 19:if(r=e.sent,!a.responseInterceptor){e.next=27;break}return e.next=23,a.responseInterceptor(r);case 23:if(e.t1=e.sent,e.t1){e.next=26;break}e.t1=r;case 26:r=e.t1;case 27:e.next=37;break;case 29:if(e.prev=29,e.t2=e.catch(13),r){e.next=33;break}throw e.t2;case 33:throw(o=new Error(r.statusText)).statusCode=o.status=r.status,o.responseError=e.t2,o;case 37:if(r.ok){e.next=42;break}throw(i=new Error(r.statusText)).statusCode=i.status=r.status,i.response=r,i;case 42:return e.abrupt("return",r);case 43:case"end":return e.stop()}},e,this,[[13,29]])}));return function(t){return e.apply(this,arguments)}}();var _=t.shouldDownloadAsText=function(){return/(json|xml|yaml|text)\b/.test(arguments.length>0&&void 0!==arguments[0]?arguments[0]:"")}},function(e,t){e.exports=n(40)},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function o(e){return Array.isArray(e)?e.length<1?"":"/"+e.map(function(e){return(e+"").replace(/~/g,"~0").replace(/\//g,"~1")}).join("/"):e}function i(e,t,n){return{op:"replace",path:e,value:t,meta:n}}function a(e,t,n){return f(c(e.filter(m).map(function(e){return t(e.value,n,e.path)})||[]))}function u(e,t,n){return n=n||[],Array.isArray(e)?e.map(function(e,r){return u(e,t,n.concat(r))}):p(e)?(0,w.default)(e).map(function(r){return u(e[r],t,n.concat(r))}):t(e,n[n.length-1],n)}function s(e,t,n){var r=[];if((n=n||[]).length>0){var o=t(e,n[n.length-1],n);o&&(r=r.concat(o))}if(Array.isArray(e)){var i=e.map(function(e,r){return s(e,t,n.concat(r))});i&&(r=r.concat(i))}else if(p(e)){var a=(0,w.default)(e).map(function(r){return s(e[r],t,n.concat(r))});a&&(r=r.concat(a))}return c(r)}function l(e){return Array.isArray(e)?e:[e]}function c(e){var t;return(t=[]).concat.apply(t,(0,_.default)(e.map(function(e){return Array.isArray(e)?c(e):e})))}function f(e){return e.filter(function(e){return void 0!==e})}function p(e){return e&&"object"===(void 0===e?"undefined":(0,b.default)(e))}function d(e){return e&&"function"==typeof e}function h(e){if(g(e)){var t=e.op;return"add"===t||"remove"===t||"replace"===t}return!1}function v(e){return h(e)||g(e)&&"mutation"===e.type}function m(e){return v(e)&&("add"===e.op||"replace"===e.op||"merge"===e.op||"mergeDeep"===e.op)}function g(e){return e&&"object"===(void 0===e?"undefined":(0,b.default)(e))}function y(e,t){try{return S.default.getValueByPointer(e,t)}catch(e){return console.error(e),{}}}Object.defineProperty(t,"__esModule",{value:!0});var b=r(n(1)),_=r(n(34)),w=r(n(0)),E=r(n(35)),x=r(n(2)),S=r(n(36)),C=r(n(4)),k=r(n(37)),A=r(n(38));t.default={add:function(e,t){return{op:"add",path:e,value:t}},replace:i,remove:function(e,t){return{op:"remove",path:e}},merge:function(e,t){return{type:"mutation",op:"merge",path:e,value:t}},mergeDeep:function(e,t){return{type:"mutation",op:"mergeDeep",path:e,value:t}},context:function(e,t){return{type:"context",path:e,value:t}},getIn:function(e,t){return t.reduce(function(e,t){return void 0!==t&&e?e[t]:e},e)},applyPatch:function(e,t,n){if(n=n||{},"merge"===(t=(0,x.default)({},t,{path:t.path&&o(t.path)})).op){var r=y(e,t.path);(0,x.default)(r,t.value),S.default.applyPatch(e,[i(t.path,r)])}else if("mergeDeep"===t.op){var a=y(e,t.path);for(var u in t.value){var s=t.value[u],l=Array.isArray(s);if(l){var c=a[u]||[];a[u]=c.concat(s)}else if(p(s)&&!l){var f=(0,x.default)({},a[u]);for(var d in s){if(Object.prototype.hasOwnProperty.call(f,d)){f=(0,k.default)((0,A.default)({},f),s);break}(0,x.default)(f,(0,E.default)({},d,s[d]))}a[u]=f}else a[u]=s}}else if("add"===t.op&&""===t.path&&p(t.value)){var h=(0,w.default)(t.value).reduce(function(e,n){return e.push({op:"add",path:"/"+o(n),value:t.value[n]}),e},[]);S.default.applyPatch(e,h)}else if("replace"===t.op&&""===t.path){var v=t.value;n.allowMetaPatches&&t.meta&&m(t)&&(Array.isArray(t.value)||p(t.value))&&(v=(0,x.default)({},v,t.meta)),e=v}else if(S.default.applyPatch(e,[t]),n.allowMetaPatches&&t.meta&&m(t)&&(Array.isArray(t.value)||p(t.value))){var g=y(e,t.path),b=(0,x.default)({},g,t.meta);S.default.applyPatch(e,[i(t.path,b)])}return e},parentPathMatch:function(e,t){if(!Array.isArray(t))return!1;for(var n=0,r=t.length;n1&&void 0!==arguments[1]?arguments[1]:{},n=t.requestInterceptor,r=t.responseInterceptor,o=e.withCredentials?"include":"same-origin";return function(t){return e({url:t,loadSpec:!0,requestInterceptor:n,responseInterceptor:r,headers:{Accept:"application/json"},credentials:o}).then(function(e){return e.body})}}Object.defineProperty(t,"__esModule",{value:!0});var i=r(n(4)),a=r(n(11));t.makeFetchJSON=o,t.clearCache=function(){s.plugins.refs.clearCache()},t.default=function(e){function t(e){var t=this;E&&(s.plugins.refs.docCache[E]=e),s.plugins.refs.fetchJSON=o(w,{requestInterceptor:y,responseInterceptor:b});var n=[s.plugins.refs];return"function"==typeof g&&n.push(s.plugins.parameters),"function"==typeof m&&n.push(s.plugins.properties),"strict"!==p&&n.push(s.plugins.allOf),(0,l.default)({spec:e,context:{baseDoc:E},plugins:n,allowMetaPatches:h,pathDiscriminator:v,parameterMacro:g,modelPropertyMacro:m}).then(_?function(){var e=(0,a.default)(i.default.mark(function e(n){return i.default.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:return e.abrupt("return",n);case 1:case"end":return e.stop()}},e,t)}));return function(t){return e.apply(this,arguments)}}():c.normalizeSwagger)}var n=e.fetch,r=e.spec,f=e.url,p=e.mode,d=e.allowMetaPatches,h=void 0===d||d,v=e.pathDiscriminator,m=e.modelPropertyMacro,g=e.parameterMacro,y=e.requestInterceptor,b=e.responseInterceptor,_=e.skipNormalization,w=e.http,E=e.baseDoc;return E=E||f,w=n||w||u.default,r?t(r):o(w,{requestInterceptor:y,responseInterceptor:b})(E).then(t)};var u=r(n(7)),s=n(31),l=r(s),c=n(5)},function(e,t){e.exports=n(204)},function(e,t){e.exports=n(91)},function(e,t){e.exports=n(2)},function(e,t){e.exports=n(3)},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(e,t){function n(){Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=(new Error).stack;for(var e=arguments.length,n=Array(e),r=0;r-1&&-1===o.indexOf(n)||i.indexOf(u)>-1||a.some(function(e){return u.indexOf(e)>-1})};var r=["properties"],o=["properties"],i=["definitions","parameters","responses","securityDefinitions","components/schemas","components/responses","components/parameters","components/securitySchemes"],a=["schema/example"]},function(e,t,n){e.exports=n(24)},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function o(e){var t=this,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if("string"==typeof e?n.url=e:n=e,!(this instanceof o))return new o(n);(0,a.default)(this,n);var r=this.resolve().then(function(){return t.disableInterfaces||(0,a.default)(t,o.makeApisTagOperation(t)),t});return r.client=this,r}Object.defineProperty(t,"__esModule",{value:!0});var i=r(n(3)),a=r((r(n(25)),n(6))),u=r(n(14)),s=r(n(10)),l=n(7),c=r(l),f=n(16),p=r(f),d=r(n(48)),h=n(49),v=n(51),m=n(5);o.http=c.default,o.makeHttp=l.makeHttp.bind(null,o.http),o.resolve=p.default,o.resolveSubtree=d.default,o.execute=v.execute,o.serializeRes=l.serializeRes,o.serializeHeaders=l.serializeHeaders,o.clearCache=f.clearCache,o.parameterBuilders=v.PARAMETER_BUILDERS,o.makeApisTagOperation=h.makeApisTagOperation,o.buildRequest=v.buildRequest,o.helpers={opId:m.opId},o.prototype={http:c.default,execute:function(e){return this.applyDefaults(),o.execute((0,i.default)({spec:this.spec,http:this.http,securities:{authorized:this.authorizations},contextUrl:"string"==typeof this.url?this.url:void 0},e))},resolve:function(){var e=this;return o.resolve({spec:this.spec,url:this.url,allowMetaPatches:this.allowMetaPatches,requestInterceptor:this.requestInterceptor||null,responseInterceptor:this.responseInterceptor||null}).then(function(t){return e.originalSpec=e.spec,e.spec=t.spec,e.errors=t.errors,e})}},o.prototype.applyDefaults=function(){var e=this.spec,t=this.url;if(t&&(0,u.default)(t,"http")){var n=s.default.parse(t);e.host||(e.host=n.host),e.schemes||(e.schemes=[n.protocol.replace(":","")]),e.basePath||(e.basePath="/")}},t.default=o,e.exports=t.default},function(e,t){e.exports=n(906)},function(e,t){e.exports=n(18)},function(e,t){e.exports=n(907)},function(e,t){e.exports=n(908)},function(e,t){e.exports=n(342)},function(e,t){e.exports=n(911)},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0}),t.plugins=t.SpecMap=void 0;var o=r(n(8)),i=r(n(1)),a=r(n(17)),u=r(n(4)),s=r(n(0)),l=r(n(18)),c=r(n(32)),f=r(n(2)),p=r(n(19)),d=r(n(20));t.default=function(e){return new w(e).dispatch()};var h=r(n(33)),v=r(n(9)),m=r(n(39)),g=r(n(43)),y=r(n(44)),b=r(n(45)),_=r(n(46)),w=function(){function e(t){(0,p.default)(this,e),(0,f.default)(this,{spec:"",debugLevel:"info",plugins:[],pluginHistory:{},errors:[],mutations:[],promisedPatches:[],state:{},patches:[],context:{},contextTree:new _.default,showDebug:!1,allPatches:[],pluginProp:"specMap",libMethods:(0,f.default)((0,c.default)(this),v.default),allowMetaPatches:!1},t),this.get=this._get.bind(this),this.getContext=this._getContext.bind(this),this.hasRun=this._hasRun.bind(this),this.wrappedPlugins=this.plugins.map(this.wrapPlugin.bind(this)).filter(v.default.isFunction),this.patches.push(v.default.add([],this.spec)),this.patches.push(v.default.context([],this.context)),this.updatePatches(this.patches)}return(0,d.default)(e,[{key:"debug",value:function(e){if(this.debugLevel===e){for(var t,n=arguments.length,r=Array(n>1?n-1:0),o=1;o1?n-1:0),o=1;o0})}},{key:"nextPromisedPatch",value:function(){if(this.promisedPatches.length>0)return a.default.race(this.promisedPatches.map(function(e){return e.value}))}},{key:"getPluginHistory",value:function(e){var t=this.getPluginName(e);return this.pluginHistory[t]||[]}},{key:"getPluginRunCount",value:function(e){return this.getPluginHistory(e).length}},{key:"getPluginHistoryTip",value:function(e){var t=this.getPluginHistory(e);return t&&t[t.length-1]||{}}},{key:"getPluginMutationIndex",value:function(e){var t=this.getPluginHistoryTip(e).mutationIndex;return"number"!=typeof t?-1:t}},{key:"getPluginName",value:function(e){return e.pluginName}},{key:"updatePluginHistory",value:function(e,t){var n=this.getPluginName(e);(this.pluginHistory[n]=this.pluginHistory[n]||[]).push(t)}},{key:"updatePatches",value:function(e,t){var n=this;v.default.normalizeArray(e).forEach(function(e){if(e instanceof Error)n.errors.push(e);else try{if(!v.default.isObject(e))return void n.debug("updatePatches","Got a non-object patch",e);if(n.showDebug&&n.allPatches.push(e),v.default.isPromise(e.value))return n.promisedPatches.push(e),void n.promisedPatchThen(e);if(v.default.isContextPatch(e))return void n.setContext(e.path,e.value);if(v.default.isMutation(e))return void n.updateMutations(e)}catch(e){console.error(e),n.errors.push(e)}})}},{key:"updateMutations",value:function(e){"object"===(0,i.default)(e.value)&&!Array.isArray(e.value)&&this.allowMetaPatches&&(e.value=(0,f.default)({},e.value));var t=v.default.applyPatch(this.state,e,{allowMetaPatches:this.allowMetaPatches});t&&(this.mutations.push(e),this.state=t)}},{key:"removePromisedPatch",value:function(e){var t=this.promisedPatches.indexOf(e);t<0?this.debug("Tried to remove a promisedPatch that isn't there!"):this.promisedPatches.splice(t,1)}},{key:"promisedPatchThen",value:function(e){var t=this;return e.value=e.value.then(function(n){var r=(0,f.default)({},e,{value:n});t.removePromisedPatch(e),t.updatePatches(r)}).catch(function(n){t.removePromisedPatch(e),t.updatePatches(n)})}},{key:"getMutations",value:function(e,t){return e=e||0,"number"!=typeof t&&(t=this.mutations.length),this.mutations.slice(e,t)}},{key:"getCurrentMutations",value:function(){return this.getMutationsForPlugin(this.getCurrentPlugin())}},{key:"getMutationsForPlugin",value:function(e){var t=this.getPluginMutationIndex(e);return this.getMutations(t+1)}},{key:"getCurrentPlugin",value:function(){return this.currentPlugin}},{key:"getPatchesOfType",value:function(e,t){return e.filter(t)}},{key:"getLib",value:function(){return this.libMethods}},{key:"_get",value:function(e){return v.default.getIn(this.state,e)}},{key:"_getContext",value:function(e){return this.contextTree.get(e)}},{key:"setContext",value:function(e,t){return this.contextTree.set(e,t)}},{key:"_hasRun",value:function(e){return this.getPluginRunCount(this.getCurrentPlugin())>(e||0)}},{key:"_clone",value:function(e){return JSON.parse((0,o.default)(e))}},{key:"dispatch",value:function(){function e(e){e&&(e=v.default.fullyNormalizeArray(e),n.updatePatches(e,r))}var t=this,n=this,r=this.nextPlugin();if(!r){var o=this.nextPromisedPatch();if(o)return o.then(function(){return t.dispatch()}).catch(function(){return t.dispatch()});var i={spec:this.state,errors:this.errors};return this.showDebug&&(i.patches=this.allPatches),a.default.resolve(i)}if(n.pluginCount=n.pluginCount||{},n.pluginCount[r]=(n.pluginCount[r]||0)+1,n.pluginCount[r]>100)return a.default.resolve({spec:n.state,errors:n.errors.concat(new Error("We've reached a hard limit of 100 plugin runs"))});if(r!==this.currentPlugin&&this.promisedPatches.length){var u=this.promisedPatches.map(function(e){return e.value});return a.default.all(u.map(function(e){return e.then(Function,Function)})).then(function(){return t.dispatch()})}return function(){n.currentPlugin=r;var t=n.getCurrentMutations(),o=n.mutations.length-1;try{if(r.isGenerator){var i=!0,a=!1,u=void 0;try{for(var s,p=(0,l.default)(r(t,n.getLib()));!(i=(s=p.next()).done);i=!0)e(s.value)}catch(e){a=!0,u=e}finally{try{!i&&p.return&&p.return()}finally{if(a)throw u}}}else e(r(t,n.getLib()))}catch(t){console.error(t),e([(0,f.default)((0,c.default)(t),{plugin:r})])}finally{n.updatePluginHistory(r,{mutationIndex:o})}return n.dispatch()}()}}]),e}(),E={refs:m.default,allOf:g.default,parameters:y.default,properties:b.default};t.SpecMap=w,t.plugins=E},function(e,t){e.exports=n(349)},function(e,t){e.exports=n(287)},function(e,t){e.exports=n(83)},function(e,t){e.exports=n(22)},function(e,t){e.exports=n(912)},function(e,t){e.exports=n(179)},function(e,t){e.exports=n(915)},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function o(e,t){if(!A.test(e)){if(!t)throw new O("Tried to resolve a relative URL, without having a basePath. path: '"+e+"' basePath: '"+t+"'");return x.default.resolve(t,e)}return e}function i(e,t){return new O("Could not resolve reference because of: "+e.message,t,e)}function a(e){return(e+"").split("#")}function u(e,t){var n=P[e];if(n&&!S.default.isPromise(n))try{var r=l(t,n);return(0,b.default)(g.default.resolve(r),{__value:r})}catch(e){return g.default.reject(e)}return s(e).then(function(e){return l(t,e)})}function s(e){var t=P[e];return t?S.default.isPromise(t)?t:g.default.resolve(t):(P[e]=I.fetchJSON(e).then(function(t){return P[e]=t,t}),P[e])}function l(e,t){var n=c(e);if(n.length<1)return t;var r=S.default.getIn(t,n);if(void 0===r)throw new O("Could not resolve pointer: "+e+" does not exist in document",{pointer:e});return r}function c(e){if("string"!=typeof e)throw new TypeError("Expected a string, got a "+(void 0===e?"undefined":(0,v.default)(e)));return"/"===e[0]&&(e=e.substr(1)),""===e?[]:e.split("/").map(f)}function f(e){return"string"!=typeof e?e:E.default.unescape(e.replace(/~1/g,"/").replace(/~0/g,"~"))}function p(e){return E.default.escape(e.replace(/~/g,"~0").replace(/\//g,"~1"))}function d(e,t){if(j(t))return!0;var n=e.charAt(t.length),r=t.slice(-1);return 0===e.indexOf(t)&&(!n||"/"===n||"#"===n)&&"#"!==r}function h(e,t,n,r){var o=T.get(r);o||(o={},T.set(r,o));var i=function(e){return 0===e.length?"":"/"+e.map(p).join("/")}(n),a=(t||"")+"#"+e;if(t==r.contextTree.get([]).baseDoc&&d(i,e))return!0;var u="";if(n.some(function(e){return u=u+"/"+p(e),o[u]&&o[u].some(function(e){return d(e,a)||d(a,e)})}))return!0;o[i]=(o[i]||[]).concat(a)}Object.defineProperty(t,"__esModule",{value:!0});var v=r(n(1)),m=r(n(0)),g=r(n(17)),y=r(n(40)),b=r(n(2)),_=n(41),w=r(n(15)),E=r(n(42)),x=r(n(10)),S=r(n(9)),C=r(n(21)),k=n(22),A=new RegExp("^([a-z]+://|//)","i"),O=(0,C.default)("JSONRefError",function(e,t,n){this.originalError=n,(0,b.default)(this,t||{})}),P={},T=new y.default,M={key:"$ref",plugin:function(e,t,n,r){var s=n.slice(0,-1);if(!(0,k.isFreelyNamed)(s)){var l=r.getContext(n).baseDoc;if("string"!=typeof e)return new O("$ref: must be a string (JSON-Ref)",{$ref:e,baseDoc:l,fullPath:n});var f=a(e),p=f[0],d=f[1]||"",v=void 0;try{v=l||p?o(p,l):null}catch(t){return i(t,{pointer:d,$ref:e,basePath:v,fullPath:n})}var g=void 0,y=void 0;if(!h(d,v,s,r)){if(null==v?(y=c(d),void 0===(g=r.get(y))&&(g=new O("Could not resolve reference: "+e,{pointer:d,$ref:e,baseDoc:l,fullPath:n}))):g=null!=(g=u(v,d)).__value?g.__value:g.catch(function(t){throw i(t,{pointer:d,$ref:e,baseDoc:l,fullPath:n})}),g instanceof Error)return[S.default.remove(n),g];var b=S.default.replace(s,g,{$$ref:e});if(v&&v!==l)return[b,S.default.context(s,{baseDoc:v})];try{if(!function(e,t){var n=[e];return t.path.reduce(function(e,t){return n.push(e[t]),e[t]},e),function e(t){return S.default.isObject(t)&&(n.indexOf(t)>=0||(0,m.default)(t).some(function(n){return e(t[n])}))}(t.value)}(r.state,b))return b}catch(e){return null}}}}},I=(0,b.default)(M,{docCache:P,absoluteify:o,clearCache:function(e){void 0!==e?delete P[e]:(0,m.default)(P).forEach(function(e){delete P[e]})},JSONRefError:O,wrapError:i,getDoc:s,split:a,extractFromDoc:u,fetchJSON:function(e){return(0,_.fetch)(e,{headers:{Accept:"application/json, application/yaml"},loadSpec:!0}).then(function(e){return e.text()}).then(function(e){return w.default.safeLoad(e)})},extract:l,jsonPointerToArray:c,unescapeJsonPointerToken:f});t.default=I;var j=function(e){return!e||"/"===e||"#"===e};e.exports=t.default},function(e,t){e.exports=n(916)},function(e,t){e.exports=n(927)},function(e,t){e.exports=n(928)},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=function(e){return e&&e.__esModule?e:{default:e}}(n(2)),o=n(22);t.default={key:"allOf",plugin:function(e,t,n,i,a){if(!a.meta||!a.meta.$$ref){var u=n.slice(0,-1);if(!(0,o.isFreelyNamed)(u)){if(!Array.isArray(e)){var s=new TypeError("allOf must be an array");return s.fullPath=n,s}var l=!1,c=a.value;u.forEach(function(e){c&&(c=c[e])}),delete(c=(0,r.default)({},c)).allOf;var f=[i.replace(u,{})].concat(e.map(function(e,t){if(!i.isObject(e)){if(l)return null;l=!0;var r=new TypeError("Elements in allOf must be objects");return r.fullPath=n,r}return i.mergeDeep(u,e)}));return f.push(i.mergeDeep(u,c)),c.$$ref||f.push(i.remove([].concat(u,"$$ref"))),f}}}},e.exports=t.default},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var o=r(n(2)),i=r(n(9));t.default={key:"parameters",plugin:function(e,t,n,r,a){if(Array.isArray(e)&&e.length){var u=(0,o.default)([],e),s=n.slice(0,-1),l=(0,o.default)({},i.default.getIn(r.spec,s));return e.forEach(function(e,t){try{u[t].default=r.parameterMacro(l,e)}catch(e){var o=new Error(e);return o.fullPath=n,o}}),i.default.replace(n,u)}return i.default.replace(n,e)}},e.exports=t.default},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var o=r(n(2)),i=r(n(9));t.default={key:"properties",plugin:function(e,t,n,r){var a=(0,o.default)({},e);for(var u in e)try{a[u].default=r.modelPropertyMacro(a[u])}catch(e){var s=new Error(e);return s.fullPath=n,s}return i.default.replace(n,a)}},e.exports=t.default},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function o(e,t){return i({children:{}},e,t)}function i(e,t,n){return e.value=t||{},e.protoValue=n?(0,u.default)({},n.protoValue,e.value):e.value,(0,a.default)(e.children).forEach(function(t){var n=e.children[t];e.children[t]=i(n,n.value,e)}),e}Object.defineProperty(t,"__esModule",{value:!0});var a=r(n(0)),u=r(n(3)),s=r(n(19)),l=r(n(20)),c=function(){function e(t){(0,s.default)(this,e),this.root=o(t||{})}return(0,l.default)(e,[{key:"set",value:function(e,t){var n=this.getParent(e,!0);if(n){var r=e[e.length-1],a=n.children;a[r]?i(a[r],t,n):a[r]=o(t,n)}else i(this.root,t,null)}},{key:"get",value:function(e){if((e=e||[]).length<1)return this.root.value;for(var t=this.root,n=void 0,r=void 0,o=0;o2&&void 0!==arguments[2]?arguments[2]:{};return o.default.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:return r=y.returnEntireTree,a=y.baseDoc,c=y.requestInterceptor,f=y.responseInterceptor,p=y.parameterMacro,d=y.modelPropertyMacro,h={pathDiscriminator:n,baseDoc:a,requestInterceptor:c,responseInterceptor:f,parameterMacro:p,modelPropertyMacro:d},v=(0,l.normalizeSwagger)({spec:t}),m=v.spec,e.next=5,(0,s.default)((0,i.default)({},h,{spec:m,allowMetaPatches:!0,skipNormalization:!0}));case 5:return g=e.sent,!r&&Array.isArray(n)&&n.length&&(g.spec=(0,u.default)(g.spec,n)||null),e.abrupt("return",g);case 8:case"end":return e.stop()}},e,this)}));return function(t,n){return e.apply(this,arguments)}}(),e.exports=t.default},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function o(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return function(t){var n=t.pathName,r=t.method,o=t.operationId;return function(t){var i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return e.execute((0,a.default)({spec:e.spec},(0,u.default)(e,"requestInterceptor","responseInterceptor","userFetch"),{pathName:n,method:r,parameters:t,operationId:o},i))}}}function i(e){var t=e.spec,n=e.cb,r=void 0===n?l:n,o=e.defaultTag,i=void 0===o?"default":o,a=e.v2OperationIdCompatibilityMode,u={},f={};return(0,s.eachOperation)(t,function(e){var n=e.pathName,o=e.method,l=e.operation;(l.tags?c(l.tags):[i]).forEach(function(e){if("string"==typeof e){var i=f[e]=f[e]||{},c=(0,s.opId)(l,n,o,{v2OperationIdCompatibilityMode:a}),p=r({spec:t,pathName:n,method:o,operation:l,operationId:c});if(u[c])u[c]++,i[""+c+u[c]]=p;else if(void 0!==i[c]){var d=u[c]||1;u[c]=d+1,i[""+c+u[c]]=p;var h=i[c];delete i[c],i[""+c+d]=h}else i[c]=p}})}),f}Object.defineProperty(t,"__esModule",{value:!0}),t.self=void 0;var a=r(n(3));t.makeExecute=o,t.makeApisTagOperationsOperationExecute=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=f.makeExecute(e),n=f.mapTagOperations({v2OperationIdCompatibilityMode:e.v2OperationIdCompatibilityMode,spec:e.spec,cb:t}),r={};for(var o in n)for(var i in r[o]={operations:{}},n[o])r[o].operations[i]={execute:n[o][i]};return{apis:r}},t.makeApisTagOperation=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=f.makeExecute(e);return{apis:f.mapTagOperations({v2OperationIdCompatibilityMode:e.v2OperationIdCompatibilityMode,spec:e.spec,cb:t})}},t.mapTagOperations=i;var u=r(n(50)),s=n(5),l=function(){return null},c=function(e){return Array.isArray(e)?e:[e]},f=t.self={mapTagOperations:i,makeExecute:o}},function(e,t){e.exports=n(929)},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function o(e){var t=e.spec,n=e.operationId,r=(e.securities,e.requestContentType,e.responseContentType),o=e.scheme,a=e.requestInterceptor,s=e.responseInterceptor,c=e.contextUrl,f=e.userFetch,p=(e.requestBody,e.server),d=e.serverVariables,h=e.http,g=e.parameters,y=e.parameterBuilders,O=(0,x.isOAS3)(t);y||(y=O?_.default:b.default);var P={url:"",credentials:h&&h.withCredentials?"include":"same-origin",headers:{},cookies:{}};a&&(P.requestInterceptor=a),s&&(P.responseInterceptor=s),f&&(P.userFetch=f);var T=(0,x.getOperationRaw)(t,n);if(!T)throw new C("Operation "+n+" not found");var M=T.operation,I=void 0===M?{}:M,j=T.method,N=T.pathName;if(P.url+=i({spec:t,scheme:o,contextUrl:c,server:p,serverVariables:d,pathName:N,method:j}),!n)return delete P.cookies,P;P.url+=N,P.method=(""+j).toUpperCase(),g=g||{};var R=t.paths[N]||{};r&&(P.headers.accept=r);var D=A([].concat(S(I.parameters)).concat(S(R.parameters)));D.forEach(function(e){var n=y[e.in],r=void 0;if("body"===e.in&&e.schema&&e.schema.properties&&(r=g),void 0===(r=e&&e.name&&g[e.name])?r=e&&e.name&&g[e.in+"."+e.name]:k(e.name,D).length>1&&console.warn("Parameter '"+e.name+"' is ambiguous because the defined spec has more than one parameter with the name: '"+e.name+"' and the passed-in parameter values did not define an 'in' value."),null!==r){if(void 0!==e.default&&void 0===r&&(r=e.default),void 0===r&&e.required&&!e.allowEmptyValue)throw new Error("Required parameter "+e.name+" is not provided");if(O&&e.schema&&"object"===e.schema.type&&"string"==typeof r)try{r=JSON.parse(r)}catch(e){throw new Error("Could not parse object parameter value string as JSON")}n&&n({req:P,parameter:e,value:r,operation:I,spec:t})}});var L=(0,u.default)({},e,{operation:I});if((P=O?(0,w.default)(L,P):(0,E.default)(L,P)).cookies&&(0,l.default)(P.cookies).length){var U=(0,l.default)(P.cookies).reduce(function(e,t){var n=P.cookies[t];return e+(e?"&":"")+v.default.serialize(t,n)},"");P.headers.Cookie=U}return P.cookies&&delete P.cookies,(0,m.mergeInQueryOrForm)(P),P}function i(e){return(0,x.isOAS3)(e.spec)?function(e){var t=e.spec,n=e.pathName,r=e.method,o=e.server,i=e.contextUrl,a=e.serverVariables,u=void 0===a?{}:a,s=(0,f.default)(t,["paths",n,(r||"").toLowerCase(),"servers"])||(0,f.default)(t,["paths",n,"servers"])||(0,f.default)(t,["servers"]),l="",c=null;if(o&&s&&s.length){var p=s.map(function(e){return e.url});p.indexOf(o)>-1&&(l=o,c=s[p.indexOf(o)])}!l&&s&&s.length&&(l=s[0].url,c=s[0]),l.indexOf("{")>-1&&function(e){for(var t=[],n=/{([^}]+)}/g,r=void 0;r=n.exec(e);)t.push(r[1]);return t}(l).forEach(function(e){if(c.variables&&c.variables[e]){var t=c.variables[e],n=u[e]||t.default,r=new RegExp("{"+e+"}","g");l=l.replace(r,n)}});return function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",n=h.default.parse(e),r=h.default.parse(t),o=P(n.protocol)||P(r.protocol)||"",i=n.host||r.host,a=n.pathname||"",u=void 0;return"/"===(u=o&&i?o+"://"+(i+a):a)[u.length-1]?u.slice(0,-1):u}(l,i)}(e):function(e){var t=e.spec,n=e.scheme,r=e.contextUrl,o=void 0===r?"":r,i=h.default.parse(o),a=Array.isArray(t.schemes)?t.schemes[0]:null,u=n||a||P(i.protocol)||"http",s=t.host||i.host||"",l=t.basePath||"",c=void 0;return"/"===(c=u&&s?u+"://"+(s+l):l)[c.length-1]?c.slice(0,-1):c}(e)}Object.defineProperty(t,"__esModule",{value:!0}),t.self=void 0;var a=r(n(8)),u=r(n(3)),s=r(n(52)),l=r(n(0)),c=r(n(2));t.execute=function(e){var t=e.http,n=e.fetch,r=e.spec,o=e.operationId,i=e.pathName,l=e.method,c=e.parameters,f=e.securities,h=(0,s.default)(e,["http","fetch","spec","operationId","pathName","method","parameters","securities"]),v=t||n||g.default;i&&l&&!o&&(o=(0,x.legacyIdFromPathMethod)(i,l));var m=O.buildRequest((0,u.default)({spec:r,operationId:o,parameters:c,securities:f,http:v},h));return m.body&&((0,p.default)(m.body)||(0,d.default)(m.body))&&(m.body=(0,a.default)(m.body)),v(m)},t.buildRequest=o,t.baseUrl=i;var f=r((r(n(6)),n(12))),p=r(n(53)),d=r(n(54)),h=r((r(n(13)),n(10))),v=r(n(55)),m=n(7),g=r(m),y=r(n(21)),b=r(n(56)),_=r(n(57)),w=r(n(62)),E=r(n(64)),x=n(5),S=function(e){return Array.isArray(e)?e:[]},C=(0,y.default)("OperationNotFoundError",function(e,t,n){this.originalError=n,(0,c.default)(this,t||{})}),k=function(e,t){return t.filter(function(t){return t.name===e})},A=function(e){var t={};e.forEach(function(e){t[e.in]||(t[e.in]={}),t[e.in][e.name]=e});var n=[];return(0,l.default)(t).forEach(function(e){(0,l.default)(t[e]).forEach(function(r){n.push(t[e][r])})}),n},O=t.self={buildRequest:o},P=function(e){return e?e.replace(/\W/g,""):null}},function(e,t){e.exports=n(84)},function(e,t){e.exports=n(228)},function(e,t){e.exports=n(24)},function(e,t){e.exports=n(932)},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default={body:function(e){var t=e.req,n=e.value;t.body=n},header:function(e){var t=e.req,n=e.parameter,r=e.value;t.headers=t.headers||{},void 0!==r&&(t.headers[n.name]=r)},query:function(e){var t=e.req,n=e.value,r=e.parameter;if(t.query=t.query||{},!1===n&&"boolean"===r.type&&(n="false"),0===n&&["number","integer"].indexOf(r.type)>-1&&(n="0"),n)t.query[r.name]={collectionFormat:r.collectionFormat,value:n};else if(r.allowEmptyValue&&void 0!==n){var o=r.name;t.query[o]=t.query[o]||{},t.query[o].allowEmptyValue=!0}},path:function(e){var t=e.req,n=e.value,r=e.parameter;t.url=t.url.replace("{"+r.name+"}",encodeURIComponent(n))},formData:function(e){var t=e.req,n=e.value,r=e.parameter;(n||r.allowEmptyValue)&&(t.form=t.form||{},t.form[r.name]={value:n,allowEmptyValue:r.allowEmptyValue,collectionFormat:r.collectionFormat})}},e.exports=t.default},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var o=r(n(0)),i=r(n(1)),a=r(n(58));t.default={path:function(e){var t=e.req,n=e.value,r=e.parameter,o=r.name,i=r.style,u=r.explode,s=(0,a.default)({key:r.name,value:n,style:i||"simple",explode:u||!1,escape:!0});t.url=t.url.replace("{"+o+"}",s)},query:function(e){var t=e.req,n=e.value,r=e.parameter;if(t.query=t.query||{},!1===n&&(n="false"),0===n&&(n="0"),n){var u=void 0===n?"undefined":(0,i.default)(n);"deepObject"===r.style?(0,o.default)(n).forEach(function(e){var o=n[e];t.query[r.name+"["+e+"]"]={value:(0,a.default)({key:e,value:o,style:"deepObject",escape:r.allowReserved?"unsafe":"reserved"}),skipEncoding:!0}}):"object"!==u||Array.isArray(n)||"form"!==r.style&&r.style||!r.explode&&void 0!==r.explode?t.query[r.name]={value:(0,a.default)({key:r.name,value:n,style:r.style||"form",explode:void 0===r.explode||r.explode,escape:r.allowReserved?"unsafe":"reserved"}),skipEncoding:!0}:(0,o.default)(n).forEach(function(e){var o=n[e];t.query[e]={value:(0,a.default)({key:e,value:o,style:r.style||"form",escape:r.allowReserved?"unsafe":"reserved"}),skipEncoding:!0}})}else if(r.allowEmptyValue&&void 0!==n){var s=r.name;t.query[s]=t.query[s]||{},t.query[s].allowEmptyValue=!0}},header:function(e){var t=e.req,n=e.parameter,r=e.value;t.headers=t.headers||{},u.indexOf(n.name.toLowerCase())>-1||void 0!==r&&(t.headers[n.name]=(0,a.default)({key:n.name,value:r,style:n.style||"simple",explode:void 0!==n.explode&&n.explode,escape:!1}))},cookie:function(e){var t=e.req,n=e.parameter,r=e.value;t.headers=t.headers||{};var o=void 0===r?"undefined":(0,i.default)(r);if("undefined"!==o){var u="object"===o&&!Array.isArray(r)&&n.explode?"":n.name+"=";t.headers.Cookie=u+(0,a.default)({key:n.name,value:r,escape:!1,style:n.style||"form",explode:void 0!==n.explode&&n.explode})}}};var u=["accept","authorization","content-type"];e.exports=t.default},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function o(e){var t=(arguments.length>1&&void 0!==arguments[1]?arguments[1]:{}).escape,n=arguments[2];return"number"==typeof e&&(e=e.toString()),"string"==typeof e&&e.length&&t?n?JSON.parse(e):(0,s.stringToCharArray)(e).map(function(e){return c(e)?e:l(e)&&"unsafe"===t?e:((0,u.default)(e)||[]).map(function(e){return e.toString(16).toUpperCase()}).map(function(e){return"%"+e}).join("")}).join(""):e}Object.defineProperty(t,"__esModule",{value:!0});var i=r(n(0)),a=r(n(1));t.encodeDisallowedCharacters=o,t.default=function(e){var t=e.value;return Array.isArray(t)?function(e){var t=e.key,n=e.value,r=e.style,i=e.explode,a=e.escape,u=function(e){return o(e,{escape:a})};if("simple"===r)return n.map(function(e){return u(e)}).join(",");if("label"===r)return"."+n.map(function(e){return u(e)}).join(".");if("matrix"===r)return n.map(function(e){return u(e)}).reduce(function(e,n){return!e||i?(e||"")+";"+t+"="+n:e+","+n},"");if("form"===r){var s=i?"&"+t+"=":",";return n.map(function(e){return u(e)}).join(s)}if("spaceDelimited"===r){var l=i?t+"=":"";return n.map(function(e){return u(e)}).join(" "+l)}if("pipeDelimited"===r){var c=i?t+"=":"";return n.map(function(e){return u(e)}).join("|"+c)}}(e):"object"===(void 0===t?"undefined":(0,a.default)(t))?function(e){var t=e.key,n=e.value,r=e.style,a=e.explode,u=e.escape,s=function(e){return o(e,{escape:u})},l=(0,i.default)(n);return"simple"===r?l.reduce(function(e,t){var r=s(n[t]);return(e?e+",":"")+t+(a?"=":",")+r},""):"label"===r?l.reduce(function(e,t){var r=s(n[t]);return(e?e+".":".")+t+(a?"=":".")+r},""):"matrix"===r&&a?l.reduce(function(e,t){var r=s(n[t]);return(e?e+";":";")+t+"="+r},""):"matrix"===r?l.reduce(function(e,r){var o=s(n[r]);return(e?e+",":";"+t+"=")+r+","+o},""):"form"===r?l.reduce(function(e,t){var r=s(n[t]);return(e?e+(a?"&":","):"")+t+(a?"=":",")+r},""):void 0}(e):function(e){var t=e.key,n=e.value,r=e.style,i=e.escape,a=function(e){return o(e,{escape:i})};return"simple"===r?a(n):"label"===r?"."+a(n):"matrix"===r?";"+t+"="+a(n):"form"===r?a(n):"deepObject"===r?a(n):void 0}(e)};var u=r((r(n(59)),n(60))),s=n(61),l=function(e){return":/?#[]@!$&'()*+,;=".indexOf(e)>-1},c=function(e){return/^[a-z0-9\-._~]+$/i.test(e)}},function(e,t){e.exports=n(933)},function(e,t){e.exports=n(934)},function(e,t){e.exports=n(935)},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function o(e){var t=e.request,n=e.securities,r=void 0===n?{}:n,o=e.operation,i=void 0===o?{}:o,a=e.spec,f=(0,s.default)({},t),p=r.authorized,d=void 0===p?{}:p,h=i.security||a.security||[],v=d&&!!(0,u.default)(d).length,m=(0,l.default)(a,["components","securitySchemes"])||{};return f.headers=f.headers||{},f.query=f.query||{},(0,u.default)(r).length&&v&&h&&(!Array.isArray(i.security)||i.security.length)?(h.forEach(function(e,t){for(var n in e){var r=d[n],o=m[n];if(r){var i=r.value||r,a=o.type;if(r)if("apiKey"===a)"query"===o.in&&(f.query[o.name]=i),"header"===o.in&&(f.headers[o.name]=i),"cookie"===o.in&&(f.cookies[o.name]=i);else if("http"===a){if("basic"===o.scheme){var u=i.username,s=i.password,l=(0,c.default)(u+":"+s);f.headers.Authorization="Basic "+l}"bearer"===o.scheme&&(f.headers.Authorization="Bearer "+i)}else if("oauth2"===a){var p=r.token||{},h=p.access_token,v=p.token_type;v&&"bearer"!==v.toLowerCase()||(v="Bearer"),f.headers.Authorization=v+" "+h}}}}),f):t}Object.defineProperty(t,"__esModule",{value:!0});var i=r(n(8)),a=r(n(1)),u=r(n(0));t.default=function(e,t){var n=e.operation,r=e.requestBody,s=e.securities,l=e.spec,c=e.attachContentTypeForEmptyPayload,p=e.requestContentType;t=o({request:t,securities:s,operation:n,spec:l});var d=n.requestBody||{},h=(0,u.default)(d.content||{}),v=p&&h.indexOf(p)>-1;if(r||c){if(p&&v)t.headers["Content-Type"]=p;else if(!p){var m=h[0];m&&(t.headers["Content-Type"]=m,p=m)}}else p&&v&&(t.headers["Content-Type"]=p);return r&&(p?h.indexOf(p)>-1&&("application/x-www-form-urlencoded"===p||0===p.indexOf("multipart/")?"object"===(void 0===r?"undefined":(0,a.default)(r))?(t.form={},(0,u.default)(r).forEach(function(e){var n,o=r[e],u=void 0;"undefined"!=typeof File&&(u=o instanceof File),"undefined"!=typeof Blob&&(u=u||o instanceof Blob),void 0!==f.Buffer&&(u=u||f.Buffer.isBuffer(o)),n="object"!==(void 0===o?"undefined":(0,a.default)(o))||u?o:Array.isArray(o)?o.toString():(0,i.default)(o),t.form[e]={value:n}})):t.form=r:t.body=r):t.body=r),t},t.applySecurities=o;var s=r(n(6)),l=r(n(12)),c=r(n(13)),f=n(63)},function(e,t){e.exports=n(55)},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function o(e){var t=e.request,n=e.securities,r=void 0===n?{}:n,o=e.operation,s=void 0===o?{}:o,l=e.spec,c=(0,u.default)({},t),f=r.authorized,p=void 0===f?{}:f,d=r.specSecurity,h=void 0===d?[]:d,v=s.security||h,m=p&&!!(0,i.default)(p).length,g=l.securityDefinitions;return c.headers=c.headers||{},c.query=c.query||{},(0,i.default)(r).length&&m&&v&&(!Array.isArray(s.security)||s.security.length)?(v.forEach(function(e,t){for(var n in e){var r=p[n];if(r){var o=r.token,i=r.value||r,u=g[n],s=u.type,l=u["x-tokenName"]||"access_token",f=o&&o[l],d=o&&o.token_type;if(r)if("apiKey"===s){var h="query"===u.in?"query":"headers";c[h]=c[h]||{},c[h][u.name]=i}else"basic"===s?i.header?c.headers.authorization=i.header:(i.base64=(0,a.default)(i.username+":"+i.password),c.headers.authorization="Basic "+i.base64):"oauth2"===s&&f&&(d=d&&"bearer"!==d.toLowerCase()?d:"Bearer",c.headers.authorization=d+" "+f)}}}),c):t}Object.defineProperty(t,"__esModule",{value:!0});var i=r(n(0));t.default=function(e,t){var n=e.spec,r=e.operation,i=e.securities,a=e.requestContentType,u=e.attachContentTypeForEmptyPayload;if((t=o({request:t,securities:i,operation:r,spec:n})).body||t.form||u)a?t.headers["Content-Type"]=a:Array.isArray(r.consumes)?t.headers["Content-Type"]=r.consumes[0]:Array.isArray(n.consumes)?t.headers["Content-Type"]=n.consumes[0]:r.parameters&&r.parameters.filter(function(e){return"file"===e.type}).length?t.headers["Content-Type"]="multipart/form-data":r.parameters&&r.parameters.filter(function(e){return"formData"===e.in}).length&&(t.headers["Content-Type"]="application/x-www-form-urlencoded");else if(a){var s=r.parameters&&r.parameters.filter(function(e){return"body"===e.in}).length>0,l=r.parameters&&r.parameters.filter(function(e){return"formData"===e.in}).length>0;(s||l)&&(t.headers["Content-Type"]=a)}return t},t.applySecurities=o;var a=r(n(13)),u=r(n(6));r(n(7))}])},function(e,t,n){"use strict";var r=Object.prototype.hasOwnProperty,o=function(){for(var e=[],t=0;t<256;++t)e.push("%"+((t<16?"0":"")+t.toString(16)).toUpperCase());return e}(),i=function(e,t){for(var n=t&&t.plainObjects?Object.create(null):{},r=0;r=48&&i<=57||i>=65&&i<=90||i>=97&&i<=122?n+=t.charAt(r):i<128?n+=o[i]:i<2048?n+=o[192|i>>6]+o[128|63&i]:i<55296||i>=57344?n+=o[224|i>>12]+o[128|i>>6&63]+o[128|63&i]:(r+=1,i=65536+((1023&i)<<10|1023&t.charCodeAt(r)),n+=o[240|i>>18]+o[128|i>>12&63]+o[128|i>>6&63]+o[128|63&i])}return n},isBuffer:function(e){return null!==e&&void 0!==e&&!!(e.constructor&&e.constructor.isBuffer&&e.constructor.isBuffer(e))},isRegExp:function(e){return"[object RegExp]"===Object.prototype.toString.call(e)},merge:function e(t,n,o){if(!n)return t;if("object"!=typeof n){if(Array.isArray(t))t.push(n);else{if("object"!=typeof t)return[t,n];(o.plainObjects||o.allowPrototypes||!r.call(Object.prototype,n))&&(t[n]=!0)}return t}if("object"!=typeof t)return[t].concat(n);var a=t;return Array.isArray(t)&&!Array.isArray(n)&&(a=i(t,o)),Array.isArray(t)&&Array.isArray(n)?(n.forEach(function(n,i){r.call(t,i)?t[i]&&"object"==typeof t[i]?t[i]=e(t[i],n,o):t.push(n):t[i]=n}),t):Object.keys(n).reduce(function(t,i){var a=n[i];return r.call(t,i)?t[i]=e(t[i],a,o):t[i]=a,t},a)}}},function(e,t,n){"use strict";var r=String.prototype.replace,o=/%20/g;e.exports={default:"RFC3986",formatters:{RFC1738:function(e){return r.call(e,o,"+")},RFC3986:function(e){return e}},RFC1738:"RFC1738",RFC3986:"RFC3986"}},function(e,t,n){var r=Array.prototype.slice,o=n(913),i=n(914),a=e.exports=function(e,t,n){return n||(n={}),e===t||(e instanceof Date&&t instanceof Date?e.getTime()===t.getTime():!e||!t||"object"!=typeof e&&"object"!=typeof t?n.strict?e===t:e==t:function(e,t,n){var l,c;if(u(e)||u(t))return!1;if(e.prototype!==t.prototype)return!1;if(i(e))return!!i(t)&&(e=r.call(e),t=r.call(t),a(e,t,n));if(s(e)){if(!s(t))return!1;if(e.length!==t.length)return!1;for(l=0;l=0;l--)if(f[l]!=p[l])return!1;for(l=f.length-1;l>=0;l--)if(c=f[l],!a(e[c],t[c],n))return!1;return typeof e==typeof t}(e,t,n))};function u(e){return null===e||void 0===e}function s(e){return!(!e||"object"!=typeof e||"number"!=typeof e.length)&&("function"==typeof e.copy&&"function"==typeof e.slice&&!(e.length>0&&"number"!=typeof e[0]))}},function(e,t,n){var r={strict:!0},o=n(390),i=function(e,t){return o(e,t,r)},a=n(231);t.JsonPatchError=a.PatchError,t.deepClone=a._deepClone;var u={add:function(e,t,n){return e[t]=this.value,{newDocument:n}},remove:function(e,t,n){var r=e[t];return delete e[t],{newDocument:n,removed:r}},replace:function(e,t,n){var r=e[t];return e[t]=this.value,{newDocument:n,removed:r}},move:function(e,t,n){var r=l(n,this.path);r&&(r=a._deepClone(r));var o=c(n,{op:"remove",path:this.from}).removed;return c(n,{op:"add",path:this.path,value:o}),{newDocument:n,removed:r}},copy:function(e,t,n){var r=l(n,this.from);return c(n,{op:"add",path:this.path,value:a._deepClone(r)}),{newDocument:n}},test:function(e,t,n){return{newDocument:n,test:i(e[t],this.value)}},_get:function(e,t,n){return this.value=e[t],{newDocument:n}}},s={add:function(e,t,n){return a.isInteger(t)?e.splice(t,0,this.value):e[t]=this.value,{newDocument:n,index:t}},remove:function(e,t,n){return{newDocument:n,removed:e.splice(t,1)[0]}},replace:function(e,t,n){var r=e[t];return e[t]=this.value,{newDocument:n,removed:r}},move:u.move,copy:u.copy,test:u.test,_get:u._get};function l(e,t){if(""==t)return e;var n={op:"_get",path:t};return c(e,n),n.value}function c(e,n,r,o){if(void 0===r&&(r=!1),void 0===o&&(o=!0),r&&("function"==typeof r?r(n,0,e,n.path):p(n,0)),""===n.path){var c={newDocument:e};if("add"===n.op)return c.newDocument=n.value,c;if("replace"===n.op)return c.newDocument=n.value,c.removed=e,c;if("move"===n.op||"copy"===n.op)return c.newDocument=l(e,n.from),"move"===n.op&&(c.removed=e),c;if("test"===n.op){if(c.test=i(e,n.value),!1===c.test)throw new t.JsonPatchError("Test operation failed","TEST_OPERATION_FAILED",0,n,e);return c.newDocument=e,c}if("remove"===n.op)return c.removed=e,c.newDocument=null,c;if("_get"===n.op)return n.value=e,c;if(r)throw new t.JsonPatchError("Operation `op` property is not one of operations defined in RFC-6902","OPERATION_OP_INVALID",0,n,e);return c}o||(e=a._deepClone(e));var f=(n.path||"").split("/"),d=e,h=1,v=f.length,m=void 0,g=void 0,y=void 0;for(y="function"==typeof r?r:p;;){if(g=f[h],r&&void 0===m&&(void 0===d[g]?m=f.slice(0,h).join("/"):h==v-1&&(m=n.path),void 0!==m&&y(n,0,e,m)),h++,Array.isArray(d)){if("-"===g)g=d.length;else{if(r&&!a.isInteger(g))throw new t.JsonPatchError("Expected an unsigned base-10 integer value, making the new referenced value the array element with the zero-based index","OPERATION_PATH_ILLEGAL_ARRAY_INDEX",0,n.path,n);a.isInteger(g)&&(g=~~g)}if(h>=v){if(r&&"add"===n.op&&g>d.length)throw new t.JsonPatchError("The specified index MUST NOT be greater than the number of elements in the array","OPERATION_VALUE_OUT_OF_BOUNDS",0,n.path,n);if(!1===(c=s[n.op].call(n,d,g,e)).test)throw new t.JsonPatchError("Test operation failed","TEST_OPERATION_FAILED",0,n,e);return c}}else if(g&&-1!=g.indexOf("~")&&(g=a.unescapePathComponent(g)),h>=v){if(!1===(c=u[n.op].call(n,d,g,e)).test)throw new t.JsonPatchError("Test operation failed","TEST_OPERATION_FAILED",0,n,e);return c}d=d[g]}}function f(e,n,r,o){if(void 0===o&&(o=!0),r&&!Array.isArray(n))throw new t.JsonPatchError("Patch sequence must be an array","SEQUENCE_NOT_AN_ARRAY");o||(e=a._deepClone(e));for(var i=new Array(n.length),u=0,s=n.length;u0)throw new t.JsonPatchError('Operation `path` property must start with "/"',"OPERATION_PATH_INVALID",n,e,r);if(("move"===e.op||"copy"===e.op)&&"string"!=typeof e.from)throw new t.JsonPatchError("Operation `from` property is not present (applicable in `move` and `copy` operations)","OPERATION_FROM_REQUIRED",n,e,r);if(("add"===e.op||"replace"===e.op||"test"===e.op)&&void 0===e.value)throw new t.JsonPatchError("Operation `value` property is not present (applicable in `add`, `replace` and `test` operations)","OPERATION_VALUE_REQUIRED",n,e,r);if(("add"===e.op||"replace"===e.op||"test"===e.op)&&a.hasUndefined(e.value))throw new t.JsonPatchError("Operation `value` property is not present (applicable in `add`, `replace` and `test` operations)","OPERATION_VALUE_CANNOT_CONTAIN_UNDEFINED",n,e,r);if(r)if("add"==e.op){var i=e.path.split("/").length,s=o.split("/").length;if(i!==s+1&&i!==s)throw new t.JsonPatchError("Cannot perform an `add` operation at the desired path","OPERATION_PATH_CANNOT_ADD",n,e,r)}else if("replace"===e.op||"remove"===e.op||"_get"===e.op){if(e.path!==o)throw new t.JsonPatchError("Cannot perform the operation at a path that does not exist","OPERATION_PATH_UNRESOLVABLE",n,e,r)}else if("move"===e.op||"copy"===e.op){var l=d([{op:"_get",path:e.from,value:void 0}],r);if(l&&"OPERATION_PATH_UNRESOLVABLE"===l.name)throw new t.JsonPatchError("Cannot perform the operation from a path that does not exist","OPERATION_FROM_UNRESOLVABLE",n,e,r)}}function d(e,n,r){try{if(!Array.isArray(e))throw new t.JsonPatchError("Patch sequence must be an array","SEQUENCE_NOT_AN_ARRAY");if(n)f(a._deepClone(n),a._deepClone(e),r||!0);else{r=r||p;for(var o=0;o1&&void 0!==arguments[1]?arguments[1]:(0,a.List)();return function(e){return(e.authSelectors.definitionsToAuthorize()||(0,a.List)()).filter(function(e){return t.some(function(t){return t.get(e.keySeq().first())})})}},t.authorized=(0,i.createSelector)(s,function(e){return e.get("authorized")||(0,a.Map)()}),t.isAuthorized=function(e,t){return function(e){var n=e.authSelectors.authorized();return a.List.isList(t)?!!t.toJS().filter(function(e){return-1===(0,r.default)(e).map(function(e){return!!n.get(e)}).indexOf(!1)}).length:null}},t.getConfigs=(0,i.createSelector)(s,function(e){return e.get("configs")})},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.execute=void 0;var r,o=n(25),i=(r=o)&&r.__esModule?r:{default:r};t.execute=function(e,t){var n=t.authSelectors,r=t.specSelectors;return function(t){var o=t.path,a=t.method,u=t.operation,s=t.extras,l={authorized:n.authorized()&&n.authorized().toJS(),definitions:r.securityDefinitions()&&r.securityDefinitions().toJS(),specSecurity:r.security()&&r.security().toJS()};return e((0,i.default)({path:o,method:a,operation:u,securities:l},s))}}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(){return{fn:{shallowEqualKeys:r.shallowEqualKeys}}};var r=n(9)},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=s(n(40)),o=s(n(23));t.default=function(e){var t=e.fn,n={download:function(e){return function(n){var r=n.errActions,i=n.specSelectors,a=n.specActions,s=n.getConfigs,l=t.fetch,c=s();function f(t){if(t instanceof Error||t.status>=400)return a.updateLoadingStatus("failed"),r.newThrownErr((0,o.default)(new Error((t.message||t.statusText)+" "+e),{source:"fetch"})),void(!t.status&&t instanceof Error&&function(){try{var t=void 0;if("URL"in u.default?t=new URL(e):(t=document.createElement("a")).href=e,"https:"!==t.protocol&&"https:"===u.default.location.protocol){var n=(0,o.default)(new Error("Possible mixed-content issue? The page was loaded over https:// but a "+t.protocol+"// URL was specified. Check that you are not attempting to load mixed content."),{source:"fetch"});return void r.newThrownErr(n)}if(t.origin!==u.default.location.origin){var i=(0,o.default)(new Error("Possible cross-origin (CORS) issue? The URL origin ("+t.origin+") does not match the page ("+u.default.location.origin+"). Check the server returns the correct 'Access-Control-Allow-*' headers."),{source:"fetch"});r.newThrownErr(i)}}catch(e){return}}());a.updateLoadingStatus("success"),a.updateSpec(t.text),i.url()!==e&&a.updateUrl(e)}e=e||i.url(),a.updateLoadingStatus("loading"),r.clear({source:"fetch"}),l({url:e,loadSpec:!0,requestInterceptor:c.requestInterceptor||function(e){return e},responseInterceptor:c.responseInterceptor||function(e){return e},credentials:"same-origin",headers:{Accept:"application/json,*/*"}}).then(f,f)}},updateLoadingStatus:function(e){var t=[null,"loading","failed","success","failedConfig"];return-1===t.indexOf(e)&&console.error("Error: "+e+" is not one of "+(0,r.default)(t)),{type:"spec_update_loading_status",payload:e}}},s={loadingStatus:(0,i.createSelector)(function(e){return e||(0,a.Map)()},function(e){return e.get("loadingStatus")||null})};return{statePlugins:{spec:{actions:n,reducers:{spec_update_loading_status:function(e,t){return"string"==typeof t.payload?e.set("loadingStatus",t.payload):e}},selectors:s}}}};var i=n(58),a=n(7),u=s(n(33));function s(e){return e&&e.__esModule?e:{default:e}}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(){return{statePlugins:{spec:{actions:a,selectors:f},configs:{reducers:s.default,actions:i,selectors:u}}}};var r=c(n(936)),o=n(234),i=l(n(235)),a=l(n(400)),u=l(n(401)),s=c(n(402));function l(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n]);return t.default=e,t}function c(e){return e&&e.__esModule?e:{default:e}}var f={getLocalConfig:function(){return(0,o.parseYamlConfig)(r.default)}}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.getConfigByUrl=t.downloadConfig=void 0;var r=n(234);t.downloadConfig=function(e){return function(t){return(0,t.fn.fetch)(e)}},t.getConfigByUrl=function(e,t){return function(n){var o=n.specActions;if(e)return o.downloadConfig(e).then(i,i);function i(n){n instanceof Error||n.status>=400?(o.updateLoadingStatus("failedConfig"),o.updateLoadingStatus("failedConfig"),o.updateUrl(""),console.error(n.statusText+" "+e.url),t(null)):t((0,r.parseYamlConfig)(n.text))}}}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});t.get=function(e,t){return e.getIn(Array.isArray(t)?t:[t])}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r,o,i=n(22),a=(r=i)&&r.__esModule?r:{default:r},u=n(7),s=n(235);t.default=(o={},(0,a.default)(o,s.UPDATE_CONFIGS,function(e,t){return e.merge((0,u.fromJS)(t.payload))}),(0,a.default)(o,s.TOGGLE_CONFIGS,function(e,t){var n=t.payload,r=e.get(n);return e.set(n,!r)}),o)},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(){return[r.default,{statePlugins:{configs:{wrapActions:{loaded:function(e,t){return function(){e.apply(void 0,arguments);var n=window.location.hash;t.layoutActions.parseDeepLinkHash(n)}}}}},wrapComponents:{operation:o.default,OperationTag:i.default}}]};var r=a(n(404)),o=a(n(406)),i=a(n(407));function a(e){return e&&e.__esModule?e:{default:e}}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.clearScrollTo=t.scrollToElement=t.readyToScroll=t.parseDeepLinkHash=t.scrollTo=t.show=void 0;var r,o=f(n(22)),i=f(n(18)),a=n(405),u=f(n(937)),s=n(9),l=n(7),c=f(l);function f(e){return e&&e.__esModule?e:{default:e}}var p=t.show=function(e,t){var n=t.getConfigs,r=t.layoutSelectors;return function(){for(var t=arguments.length,o=Array(t),u=0;u",Gt:"≫",gt:">",gtcc:"⪧",gtcir:"⩺",gtdot:"⋗",gtlPar:"⦕",gtquest:"⩼",gtrapprox:"⪆",gtrarr:"⥸",gtrdot:"⋗",gtreqless:"⋛",gtreqqless:"⪌",gtrless:"≷",gtrsim:"≳",gvertneqq:"≩︀",gvnE:"≩︀",Hacek:"ˇ",hairsp:" ",half:"½",hamilt:"ℋ",HARDcy:"Ъ",hardcy:"ъ",hArr:"⇔",harr:"↔",harrcir:"⥈",harrw:"↭",Hat:"^",hbar:"ℏ",Hcirc:"Ĥ",hcirc:"ĥ",hearts:"♥",heartsuit:"♥",hellip:"…",hercon:"⊹",Hfr:"ℌ",hfr:"𝔥",HilbertSpace:"ℋ",hksearow:"⤥",hkswarow:"⤦",hoarr:"⇿",homtht:"∻",hookleftarrow:"↩",hookrightarrow:"↪",Hopf:"ℍ",hopf:"𝕙",horbar:"―",HorizontalLine:"─",Hscr:"ℋ",hscr:"𝒽",hslash:"ℏ",Hstrok:"Ħ",hstrok:"ħ",HumpDownHump:"≎",HumpEqual:"≏",hybull:"⁃",hyphen:"‐",Iacute:"Í",iacute:"í",ic:"⁣",Icirc:"Î",icirc:"î",Icy:"И",icy:"и",Idot:"İ",IEcy:"Е",iecy:"е",iexcl:"¡",iff:"⇔",Ifr:"ℑ",ifr:"𝔦",Igrave:"Ì",igrave:"ì",ii:"ⅈ",iiiint:"⨌",iiint:"∭",iinfin:"⧜",iiota:"℩",IJlig:"IJ",ijlig:"ij",Im:"ℑ",Imacr:"Ī",imacr:"ī",image:"ℑ",ImaginaryI:"ⅈ",imagline:"ℐ",imagpart:"ℑ",imath:"ı",imof:"⊷",imped:"Ƶ",Implies:"⇒",in:"∈",incare:"℅",infin:"∞",infintie:"⧝",inodot:"ı",Int:"∬",int:"∫",intcal:"⊺",integers:"ℤ",Integral:"∫",intercal:"⊺",Intersection:"⋂",intlarhk:"⨗",intprod:"⨼",InvisibleComma:"⁣",InvisibleTimes:"⁢",IOcy:"Ё",iocy:"ё",Iogon:"Į",iogon:"į",Iopf:"𝕀",iopf:"𝕚",Iota:"Ι",iota:"ι",iprod:"⨼",iquest:"¿",Iscr:"ℐ",iscr:"𝒾",isin:"∈",isindot:"⋵",isinE:"⋹",isins:"⋴",isinsv:"⋳",isinv:"∈",it:"⁢",Itilde:"Ĩ",itilde:"ĩ",Iukcy:"І",iukcy:"і",Iuml:"Ï",iuml:"ï",Jcirc:"Ĵ",jcirc:"ĵ",Jcy:"Й",jcy:"й",Jfr:"𝔍",jfr:"𝔧",jmath:"ȷ",Jopf:"𝕁",jopf:"𝕛",Jscr:"𝒥",jscr:"𝒿",Jsercy:"Ј",jsercy:"ј",Jukcy:"Є",jukcy:"є",Kappa:"Κ",kappa:"κ",kappav:"ϰ",Kcedil:"Ķ",kcedil:"ķ",Kcy:"К",kcy:"к",Kfr:"𝔎",kfr:"𝔨",kgreen:"ĸ",KHcy:"Х",khcy:"х",KJcy:"Ќ",kjcy:"ќ",Kopf:"𝕂",kopf:"𝕜",Kscr:"𝒦",kscr:"𝓀",lAarr:"⇚",Lacute:"Ĺ",lacute:"ĺ",laemptyv:"⦴",lagran:"ℒ",Lambda:"Λ",lambda:"λ",Lang:"⟪",lang:"⟨",langd:"⦑",langle:"⟨",lap:"⪅",Laplacetrf:"ℒ",laquo:"«",Larr:"↞",lArr:"⇐",larr:"←",larrb:"⇤",larrbfs:"⤟",larrfs:"⤝",larrhk:"↩",larrlp:"↫",larrpl:"⤹",larrsim:"⥳",larrtl:"↢",lat:"⪫",lAtail:"⤛",latail:"⤙",late:"⪭",lates:"⪭︀",lBarr:"⤎",lbarr:"⤌",lbbrk:"❲",lbrace:"{",lbrack:"[",lbrke:"⦋",lbrksld:"⦏",lbrkslu:"⦍",Lcaron:"Ľ",lcaron:"ľ",Lcedil:"Ļ",lcedil:"ļ",lceil:"⌈",lcub:"{",Lcy:"Л",lcy:"л",ldca:"⤶",ldquo:"“",ldquor:"„",ldrdhar:"⥧",ldrushar:"⥋",ldsh:"↲",lE:"≦",le:"≤",LeftAngleBracket:"⟨",LeftArrow:"←",Leftarrow:"⇐",leftarrow:"←",LeftArrowBar:"⇤",LeftArrowRightArrow:"⇆",leftarrowtail:"↢",LeftCeiling:"⌈",LeftDoubleBracket:"⟦",LeftDownTeeVector:"⥡",LeftDownVector:"⇃",LeftDownVectorBar:"⥙",LeftFloor:"⌊",leftharpoondown:"↽",leftharpoonup:"↼",leftleftarrows:"⇇",LeftRightArrow:"↔",Leftrightarrow:"⇔",leftrightarrow:"↔",leftrightarrows:"⇆",leftrightharpoons:"⇋",leftrightsquigarrow:"↭",LeftRightVector:"⥎",LeftTee:"⊣",LeftTeeArrow:"↤",LeftTeeVector:"⥚",leftthreetimes:"⋋",LeftTriangle:"⊲",LeftTriangleBar:"⧏",LeftTriangleEqual:"⊴",LeftUpDownVector:"⥑",LeftUpTeeVector:"⥠",LeftUpVector:"↿",LeftUpVectorBar:"⥘",LeftVector:"↼",LeftVectorBar:"⥒",lEg:"⪋",leg:"⋚",leq:"≤",leqq:"≦",leqslant:"⩽",les:"⩽",lescc:"⪨",lesdot:"⩿",lesdoto:"⪁",lesdotor:"⪃",lesg:"⋚︀",lesges:"⪓",lessapprox:"⪅",lessdot:"⋖",lesseqgtr:"⋚",lesseqqgtr:"⪋",LessEqualGreater:"⋚",LessFullEqual:"≦",LessGreater:"≶",lessgtr:"≶",LessLess:"⪡",lesssim:"≲",LessSlantEqual:"⩽",LessTilde:"≲",lfisht:"⥼",lfloor:"⌊",Lfr:"𝔏",lfr:"𝔩",lg:"≶",lgE:"⪑",lHar:"⥢",lhard:"↽",lharu:"↼",lharul:"⥪",lhblk:"▄",LJcy:"Љ",ljcy:"љ",Ll:"⋘",ll:"≪",llarr:"⇇",llcorner:"⌞",Lleftarrow:"⇚",llhard:"⥫",lltri:"◺",Lmidot:"Ŀ",lmidot:"ŀ",lmoust:"⎰",lmoustache:"⎰",lnap:"⪉",lnapprox:"⪉",lnE:"≨",lne:"⪇",lneq:"⪇",lneqq:"≨",lnsim:"⋦",loang:"⟬",loarr:"⇽",lobrk:"⟦",LongLeftArrow:"⟵",Longleftarrow:"⟸",longleftarrow:"⟵",LongLeftRightArrow:"⟷",Longleftrightarrow:"⟺",longleftrightarrow:"⟷",longmapsto:"⟼",LongRightArrow:"⟶",Longrightarrow:"⟹",longrightarrow:"⟶",looparrowleft:"↫",looparrowright:"↬",lopar:"⦅",Lopf:"𝕃",lopf:"𝕝",loplus:"⨭",lotimes:"⨴",lowast:"∗",lowbar:"_",LowerLeftArrow:"↙",LowerRightArrow:"↘",loz:"◊",lozenge:"◊",lozf:"⧫",lpar:"(",lparlt:"⦓",lrarr:"⇆",lrcorner:"⌟",lrhar:"⇋",lrhard:"⥭",lrm:"‎",lrtri:"⊿",lsaquo:"‹",Lscr:"ℒ",lscr:"𝓁",Lsh:"↰",lsh:"↰",lsim:"≲",lsime:"⪍",lsimg:"⪏",lsqb:"[",lsquo:"‘",lsquor:"‚",Lstrok:"Ł",lstrok:"ł",LT:"<",Lt:"≪",lt:"<",ltcc:"⪦",ltcir:"⩹",ltdot:"⋖",lthree:"⋋",ltimes:"⋉",ltlarr:"⥶",ltquest:"⩻",ltri:"◃",ltrie:"⊴",ltrif:"◂",ltrPar:"⦖",lurdshar:"⥊",luruhar:"⥦",lvertneqq:"≨︀",lvnE:"≨︀",macr:"¯",male:"♂",malt:"✠",maltese:"✠",Map:"⤅",map:"↦",mapsto:"↦",mapstodown:"↧",mapstoleft:"↤",mapstoup:"↥",marker:"▮",mcomma:"⨩",Mcy:"М",mcy:"м",mdash:"—",mDDot:"∺",measuredangle:"∡",MediumSpace:" ",Mellintrf:"ℳ",Mfr:"𝔐",mfr:"𝔪",mho:"℧",micro:"µ",mid:"∣",midast:"*",midcir:"⫰",middot:"·",minus:"−",minusb:"⊟",minusd:"∸",minusdu:"⨪",MinusPlus:"∓",mlcp:"⫛",mldr:"…",mnplus:"∓",models:"⊧",Mopf:"𝕄",mopf:"𝕞",mp:"∓",Mscr:"ℳ",mscr:"𝓂",mstpos:"∾",Mu:"Μ",mu:"μ",multimap:"⊸",mumap:"⊸",nabla:"∇",Nacute:"Ń",nacute:"ń",nang:"∠⃒",nap:"≉",napE:"⩰̸",napid:"≋̸",napos:"ʼn",napprox:"≉",natur:"♮",natural:"♮",naturals:"ℕ",nbsp:" ",nbump:"≎̸",nbumpe:"≏̸",ncap:"⩃",Ncaron:"Ň",ncaron:"ň",Ncedil:"Ņ",ncedil:"ņ",ncong:"≇",ncongdot:"⩭̸",ncup:"⩂",Ncy:"Н",ncy:"н",ndash:"–",ne:"≠",nearhk:"⤤",neArr:"⇗",nearr:"↗",nearrow:"↗",nedot:"≐̸",NegativeMediumSpace:"​",NegativeThickSpace:"​",NegativeThinSpace:"​",NegativeVeryThinSpace:"​",nequiv:"≢",nesear:"⤨",nesim:"≂̸",NestedGreaterGreater:"≫",NestedLessLess:"≪",NewLine:"\n",nexist:"∄",nexists:"∄",Nfr:"𝔑",nfr:"𝔫",ngE:"≧̸",nge:"≱",ngeq:"≱",ngeqq:"≧̸",ngeqslant:"⩾̸",nges:"⩾̸",nGg:"⋙̸",ngsim:"≵",nGt:"≫⃒",ngt:"≯",ngtr:"≯",nGtv:"≫̸",nhArr:"⇎",nharr:"↮",nhpar:"⫲",ni:"∋",nis:"⋼",nisd:"⋺",niv:"∋",NJcy:"Њ",njcy:"њ",nlArr:"⇍",nlarr:"↚",nldr:"‥",nlE:"≦̸",nle:"≰",nLeftarrow:"⇍",nleftarrow:"↚",nLeftrightarrow:"⇎",nleftrightarrow:"↮",nleq:"≰",nleqq:"≦̸",nleqslant:"⩽̸",nles:"⩽̸",nless:"≮",nLl:"⋘̸",nlsim:"≴",nLt:"≪⃒",nlt:"≮",nltri:"⋪",nltrie:"⋬",nLtv:"≪̸",nmid:"∤",NoBreak:"⁠",NonBreakingSpace:" ",Nopf:"ℕ",nopf:"𝕟",Not:"⫬",not:"¬",NotCongruent:"≢",NotCupCap:"≭",NotDoubleVerticalBar:"∦",NotElement:"∉",NotEqual:"≠",NotEqualTilde:"≂̸",NotExists:"∄",NotGreater:"≯",NotGreaterEqual:"≱",NotGreaterFullEqual:"≧̸",NotGreaterGreater:"≫̸",NotGreaterLess:"≹",NotGreaterSlantEqual:"⩾̸",NotGreaterTilde:"≵",NotHumpDownHump:"≎̸",NotHumpEqual:"≏̸",notin:"∉",notindot:"⋵̸",notinE:"⋹̸",notinva:"∉",notinvb:"⋷",notinvc:"⋶",NotLeftTriangle:"⋪",NotLeftTriangleBar:"⧏̸",NotLeftTriangleEqual:"⋬",NotLess:"≮",NotLessEqual:"≰",NotLessGreater:"≸",NotLessLess:"≪̸",NotLessSlantEqual:"⩽̸",NotLessTilde:"≴",NotNestedGreaterGreater:"⪢̸",NotNestedLessLess:"⪡̸",notni:"∌",notniva:"∌",notnivb:"⋾",notnivc:"⋽",NotPrecedes:"⊀",NotPrecedesEqual:"⪯̸",NotPrecedesSlantEqual:"⋠",NotReverseElement:"∌",NotRightTriangle:"⋫",NotRightTriangleBar:"⧐̸",NotRightTriangleEqual:"⋭",NotSquareSubset:"⊏̸",NotSquareSubsetEqual:"⋢",NotSquareSuperset:"⊐̸",NotSquareSupersetEqual:"⋣",NotSubset:"⊂⃒",NotSubsetEqual:"⊈",NotSucceeds:"⊁",NotSucceedsEqual:"⪰̸",NotSucceedsSlantEqual:"⋡",NotSucceedsTilde:"≿̸",NotSuperset:"⊃⃒",NotSupersetEqual:"⊉",NotTilde:"≁",NotTildeEqual:"≄",NotTildeFullEqual:"≇",NotTildeTilde:"≉",NotVerticalBar:"∤",npar:"∦",nparallel:"∦",nparsl:"⫽⃥",npart:"∂̸",npolint:"⨔",npr:"⊀",nprcue:"⋠",npre:"⪯̸",nprec:"⊀",npreceq:"⪯̸",nrArr:"⇏",nrarr:"↛",nrarrc:"⤳̸",nrarrw:"↝̸",nRightarrow:"⇏",nrightarrow:"↛",nrtri:"⋫",nrtrie:"⋭",nsc:"⊁",nsccue:"⋡",nsce:"⪰̸",Nscr:"𝒩",nscr:"𝓃",nshortmid:"∤",nshortparallel:"∦",nsim:"≁",nsime:"≄",nsimeq:"≄",nsmid:"∤",nspar:"∦",nsqsube:"⋢",nsqsupe:"⋣",nsub:"⊄",nsubE:"⫅̸",nsube:"⊈",nsubset:"⊂⃒",nsubseteq:"⊈",nsubseteqq:"⫅̸",nsucc:"⊁",nsucceq:"⪰̸",nsup:"⊅",nsupE:"⫆̸",nsupe:"⊉",nsupset:"⊃⃒",nsupseteq:"⊉",nsupseteqq:"⫆̸",ntgl:"≹",Ntilde:"Ñ",ntilde:"ñ",ntlg:"≸",ntriangleleft:"⋪",ntrianglelefteq:"⋬",ntriangleright:"⋫",ntrianglerighteq:"⋭",Nu:"Ν",nu:"ν",num:"#",numero:"№",numsp:" ",nvap:"≍⃒",nVDash:"⊯",nVdash:"⊮",nvDash:"⊭",nvdash:"⊬",nvge:"≥⃒",nvgt:">⃒",nvHarr:"⤄",nvinfin:"⧞",nvlArr:"⤂",nvle:"≤⃒",nvlt:"<⃒",nvltrie:"⊴⃒",nvrArr:"⤃",nvrtrie:"⊵⃒",nvsim:"∼⃒",nwarhk:"⤣",nwArr:"⇖",nwarr:"↖",nwarrow:"↖",nwnear:"⤧",Oacute:"Ó",oacute:"ó",oast:"⊛",ocir:"⊚",Ocirc:"Ô",ocirc:"ô",Ocy:"О",ocy:"о",odash:"⊝",Odblac:"Ő",odblac:"ő",odiv:"⨸",odot:"⊙",odsold:"⦼",OElig:"Œ",oelig:"œ",ofcir:"⦿",Ofr:"𝔒",ofr:"𝔬",ogon:"˛",Ograve:"Ò",ograve:"ò",ogt:"⧁",ohbar:"⦵",ohm:"Ω",oint:"∮",olarr:"↺",olcir:"⦾",olcross:"⦻",oline:"‾",olt:"⧀",Omacr:"Ō",omacr:"ō",Omega:"Ω",omega:"ω",Omicron:"Ο",omicron:"ο",omid:"⦶",ominus:"⊖",Oopf:"𝕆",oopf:"𝕠",opar:"⦷",OpenCurlyDoubleQuote:"“",OpenCurlyQuote:"‘",operp:"⦹",oplus:"⊕",Or:"⩔",or:"∨",orarr:"↻",ord:"⩝",order:"ℴ",orderof:"ℴ",ordf:"ª",ordm:"º",origof:"⊶",oror:"⩖",orslope:"⩗",orv:"⩛",oS:"Ⓢ",Oscr:"𝒪",oscr:"ℴ",Oslash:"Ø",oslash:"ø",osol:"⊘",Otilde:"Õ",otilde:"õ",Otimes:"⨷",otimes:"⊗",otimesas:"⨶",Ouml:"Ö",ouml:"ö",ovbar:"⌽",OverBar:"‾",OverBrace:"⏞",OverBracket:"⎴",OverParenthesis:"⏜",par:"∥",para:"¶",parallel:"∥",parsim:"⫳",parsl:"⫽",part:"∂",PartialD:"∂",Pcy:"П",pcy:"п",percnt:"%",period:".",permil:"‰",perp:"⊥",pertenk:"‱",Pfr:"𝔓",pfr:"𝔭",Phi:"Φ",phi:"φ",phiv:"ϕ",phmmat:"ℳ",phone:"☎",Pi:"Π",pi:"π",pitchfork:"⋔",piv:"ϖ",planck:"ℏ",planckh:"ℎ",plankv:"ℏ",plus:"+",plusacir:"⨣",plusb:"⊞",pluscir:"⨢",plusdo:"∔",plusdu:"⨥",pluse:"⩲",PlusMinus:"±",plusmn:"±",plussim:"⨦",plustwo:"⨧",pm:"±",Poincareplane:"ℌ",pointint:"⨕",Popf:"ℙ",popf:"𝕡",pound:"£",Pr:"⪻",pr:"≺",prap:"⪷",prcue:"≼",prE:"⪳",pre:"⪯",prec:"≺",precapprox:"⪷",preccurlyeq:"≼",Precedes:"≺",PrecedesEqual:"⪯",PrecedesSlantEqual:"≼",PrecedesTilde:"≾",preceq:"⪯",precnapprox:"⪹",precneqq:"⪵",precnsim:"⋨",precsim:"≾",Prime:"″",prime:"′",primes:"ℙ",prnap:"⪹",prnE:"⪵",prnsim:"⋨",prod:"∏",Product:"∏",profalar:"⌮",profline:"⌒",profsurf:"⌓",prop:"∝",Proportion:"∷",Proportional:"∝",propto:"∝",prsim:"≾",prurel:"⊰",Pscr:"𝒫",pscr:"𝓅",Psi:"Ψ",psi:"ψ",puncsp:" ",Qfr:"𝔔",qfr:"𝔮",qint:"⨌",Qopf:"ℚ",qopf:"𝕢",qprime:"⁗",Qscr:"𝒬",qscr:"𝓆",quaternions:"ℍ",quatint:"⨖",quest:"?",questeq:"≟",QUOT:'"',quot:'"',rAarr:"⇛",race:"∽̱",Racute:"Ŕ",racute:"ŕ",radic:"√",raemptyv:"⦳",Rang:"⟫",rang:"⟩",rangd:"⦒",range:"⦥",rangle:"⟩",raquo:"»",Rarr:"↠",rArr:"⇒",rarr:"→",rarrap:"⥵",rarrb:"⇥",rarrbfs:"⤠",rarrc:"⤳",rarrfs:"⤞",rarrhk:"↪",rarrlp:"↬",rarrpl:"⥅",rarrsim:"⥴",Rarrtl:"⤖",rarrtl:"↣",rarrw:"↝",rAtail:"⤜",ratail:"⤚",ratio:"∶",rationals:"ℚ",RBarr:"⤐",rBarr:"⤏",rbarr:"⤍",rbbrk:"❳",rbrace:"}",rbrack:"]",rbrke:"⦌",rbrksld:"⦎",rbrkslu:"⦐",Rcaron:"Ř",rcaron:"ř",Rcedil:"Ŗ",rcedil:"ŗ",rceil:"⌉",rcub:"}",Rcy:"Р",rcy:"р",rdca:"⤷",rdldhar:"⥩",rdquo:"”",rdquor:"”",rdsh:"↳",Re:"ℜ",real:"ℜ",realine:"ℛ",realpart:"ℜ",reals:"ℝ",rect:"▭",REG:"®",reg:"®",ReverseElement:"∋",ReverseEquilibrium:"⇋",ReverseUpEquilibrium:"⥯",rfisht:"⥽",rfloor:"⌋",Rfr:"ℜ",rfr:"𝔯",rHar:"⥤",rhard:"⇁",rharu:"⇀",rharul:"⥬",Rho:"Ρ",rho:"ρ",rhov:"ϱ",RightAngleBracket:"⟩",RightArrow:"→",Rightarrow:"⇒",rightarrow:"→",RightArrowBar:"⇥",RightArrowLeftArrow:"⇄",rightarrowtail:"↣",RightCeiling:"⌉",RightDoubleBracket:"⟧",RightDownTeeVector:"⥝",RightDownVector:"⇂",RightDownVectorBar:"⥕",RightFloor:"⌋",rightharpoondown:"⇁",rightharpoonup:"⇀",rightleftarrows:"⇄",rightleftharpoons:"⇌",rightrightarrows:"⇉",rightsquigarrow:"↝",RightTee:"⊢",RightTeeArrow:"↦",RightTeeVector:"⥛",rightthreetimes:"⋌",RightTriangle:"⊳",RightTriangleBar:"⧐",RightTriangleEqual:"⊵",RightUpDownVector:"⥏",RightUpTeeVector:"⥜",RightUpVector:"↾",RightUpVectorBar:"⥔",RightVector:"⇀",RightVectorBar:"⥓",ring:"˚",risingdotseq:"≓",rlarr:"⇄",rlhar:"⇌",rlm:"‏",rmoust:"⎱",rmoustache:"⎱",rnmid:"⫮",roang:"⟭",roarr:"⇾",robrk:"⟧",ropar:"⦆",Ropf:"ℝ",ropf:"𝕣",roplus:"⨮",rotimes:"⨵",RoundImplies:"⥰",rpar:")",rpargt:"⦔",rppolint:"⨒",rrarr:"⇉",Rrightarrow:"⇛",rsaquo:"›",Rscr:"ℛ",rscr:"𝓇",Rsh:"↱",rsh:"↱",rsqb:"]",rsquo:"’",rsquor:"’",rthree:"⋌",rtimes:"⋊",rtri:"▹",rtrie:"⊵",rtrif:"▸",rtriltri:"⧎",RuleDelayed:"⧴",ruluhar:"⥨",rx:"℞",Sacute:"Ś",sacute:"ś",sbquo:"‚",Sc:"⪼",sc:"≻",scap:"⪸",Scaron:"Š",scaron:"š",sccue:"≽",scE:"⪴",sce:"⪰",Scedil:"Ş",scedil:"ş",Scirc:"Ŝ",scirc:"ŝ",scnap:"⪺",scnE:"⪶",scnsim:"⋩",scpolint:"⨓",scsim:"≿",Scy:"С",scy:"с",sdot:"⋅",sdotb:"⊡",sdote:"⩦",searhk:"⤥",seArr:"⇘",searr:"↘",searrow:"↘",sect:"§",semi:";",seswar:"⤩",setminus:"∖",setmn:"∖",sext:"✶",Sfr:"𝔖",sfr:"𝔰",sfrown:"⌢",sharp:"♯",SHCHcy:"Щ",shchcy:"щ",SHcy:"Ш",shcy:"ш",ShortDownArrow:"↓",ShortLeftArrow:"←",shortmid:"∣",shortparallel:"∥",ShortRightArrow:"→",ShortUpArrow:"↑",shy:"­",Sigma:"Σ",sigma:"σ",sigmaf:"ς",sigmav:"ς",sim:"∼",simdot:"⩪",sime:"≃",simeq:"≃",simg:"⪞",simgE:"⪠",siml:"⪝",simlE:"⪟",simne:"≆",simplus:"⨤",simrarr:"⥲",slarr:"←",SmallCircle:"∘",smallsetminus:"∖",smashp:"⨳",smeparsl:"⧤",smid:"∣",smile:"⌣",smt:"⪪",smte:"⪬",smtes:"⪬︀",SOFTcy:"Ь",softcy:"ь",sol:"/",solb:"⧄",solbar:"⌿",Sopf:"𝕊",sopf:"𝕤",spades:"♠",spadesuit:"♠",spar:"∥",sqcap:"⊓",sqcaps:"⊓︀",sqcup:"⊔",sqcups:"⊔︀",Sqrt:"√",sqsub:"⊏",sqsube:"⊑",sqsubset:"⊏",sqsubseteq:"⊑",sqsup:"⊐",sqsupe:"⊒",sqsupset:"⊐",sqsupseteq:"⊒",squ:"□",Square:"□",square:"□",SquareIntersection:"⊓",SquareSubset:"⊏",SquareSubsetEqual:"⊑",SquareSuperset:"⊐",SquareSupersetEqual:"⊒",SquareUnion:"⊔",squarf:"▪",squf:"▪",srarr:"→",Sscr:"𝒮",sscr:"𝓈",ssetmn:"∖",ssmile:"⌣",sstarf:"⋆",Star:"⋆",star:"☆",starf:"★",straightepsilon:"ϵ",straightphi:"ϕ",strns:"¯",Sub:"⋐",sub:"⊂",subdot:"⪽",subE:"⫅",sube:"⊆",subedot:"⫃",submult:"⫁",subnE:"⫋",subne:"⊊",subplus:"⪿",subrarr:"⥹",Subset:"⋐",subset:"⊂",subseteq:"⊆",subseteqq:"⫅",SubsetEqual:"⊆",subsetneq:"⊊",subsetneqq:"⫋",subsim:"⫇",subsub:"⫕",subsup:"⫓",succ:"≻",succapprox:"⪸",succcurlyeq:"≽",Succeeds:"≻",SucceedsEqual:"⪰",SucceedsSlantEqual:"≽",SucceedsTilde:"≿",succeq:"⪰",succnapprox:"⪺",succneqq:"⪶",succnsim:"⋩",succsim:"≿",SuchThat:"∋",Sum:"∑",sum:"∑",sung:"♪",Sup:"⋑",sup:"⊃",sup1:"¹",sup2:"²",sup3:"³",supdot:"⪾",supdsub:"⫘",supE:"⫆",supe:"⊇",supedot:"⫄",Superset:"⊃",SupersetEqual:"⊇",suphsol:"⟉",suphsub:"⫗",suplarr:"⥻",supmult:"⫂",supnE:"⫌",supne:"⊋",supplus:"⫀",Supset:"⋑",supset:"⊃",supseteq:"⊇",supseteqq:"⫆",supsetneq:"⊋",supsetneqq:"⫌",supsim:"⫈",supsub:"⫔",supsup:"⫖",swarhk:"⤦",swArr:"⇙",swarr:"↙",swarrow:"↙",swnwar:"⤪",szlig:"ß",Tab:"\t",target:"⌖",Tau:"Τ",tau:"τ",tbrk:"⎴",Tcaron:"Ť",tcaron:"ť",Tcedil:"Ţ",tcedil:"ţ",Tcy:"Т",tcy:"т",tdot:"⃛",telrec:"⌕",Tfr:"𝔗",tfr:"𝔱",there4:"∴",Therefore:"∴",therefore:"∴",Theta:"Θ",theta:"θ",thetasym:"ϑ",thetav:"ϑ",thickapprox:"≈",thicksim:"∼",ThickSpace:"  ",thinsp:" ",ThinSpace:" ",thkap:"≈",thksim:"∼",THORN:"Þ",thorn:"þ",Tilde:"∼",tilde:"˜",TildeEqual:"≃",TildeFullEqual:"≅",TildeTilde:"≈",times:"×",timesb:"⊠",timesbar:"⨱",timesd:"⨰",tint:"∭",toea:"⤨",top:"⊤",topbot:"⌶",topcir:"⫱",Topf:"𝕋",topf:"𝕥",topfork:"⫚",tosa:"⤩",tprime:"‴",TRADE:"™",trade:"™",triangle:"▵",triangledown:"▿",triangleleft:"◃",trianglelefteq:"⊴",triangleq:"≜",triangleright:"▹",trianglerighteq:"⊵",tridot:"◬",trie:"≜",triminus:"⨺",TripleDot:"⃛",triplus:"⨹",trisb:"⧍",tritime:"⨻",trpezium:"⏢",Tscr:"𝒯",tscr:"𝓉",TScy:"Ц",tscy:"ц",TSHcy:"Ћ",tshcy:"ћ",Tstrok:"Ŧ",tstrok:"ŧ",twixt:"≬",twoheadleftarrow:"↞",twoheadrightarrow:"↠",Uacute:"Ú",uacute:"ú",Uarr:"↟",uArr:"⇑",uarr:"↑",Uarrocir:"⥉",Ubrcy:"Ў",ubrcy:"ў",Ubreve:"Ŭ",ubreve:"ŭ",Ucirc:"Û",ucirc:"û",Ucy:"У",ucy:"у",udarr:"⇅",Udblac:"Ű",udblac:"ű",udhar:"⥮",ufisht:"⥾",Ufr:"𝔘",ufr:"𝔲",Ugrave:"Ù",ugrave:"ù",uHar:"⥣",uharl:"↿",uharr:"↾",uhblk:"▀",ulcorn:"⌜",ulcorner:"⌜",ulcrop:"⌏",ultri:"◸",Umacr:"Ū",umacr:"ū",uml:"¨",UnderBar:"_",UnderBrace:"⏟",UnderBracket:"⎵",UnderParenthesis:"⏝",Union:"⋃",UnionPlus:"⊎",Uogon:"Ų",uogon:"ų",Uopf:"𝕌",uopf:"𝕦",UpArrow:"↑",Uparrow:"⇑",uparrow:"↑",UpArrowBar:"⤒",UpArrowDownArrow:"⇅",UpDownArrow:"↕",Updownarrow:"⇕",updownarrow:"↕",UpEquilibrium:"⥮",upharpoonleft:"↿",upharpoonright:"↾",uplus:"⊎",UpperLeftArrow:"↖",UpperRightArrow:"↗",Upsi:"ϒ",upsi:"υ",upsih:"ϒ",Upsilon:"Υ",upsilon:"υ",UpTee:"⊥",UpTeeArrow:"↥",upuparrows:"⇈",urcorn:"⌝",urcorner:"⌝",urcrop:"⌎",Uring:"Ů",uring:"ů",urtri:"◹",Uscr:"𝒰",uscr:"𝓊",utdot:"⋰",Utilde:"Ũ",utilde:"ũ",utri:"▵",utrif:"▴",uuarr:"⇈",Uuml:"Ü",uuml:"ü",uwangle:"⦧",vangrt:"⦜",varepsilon:"ϵ",varkappa:"ϰ",varnothing:"∅",varphi:"ϕ",varpi:"ϖ",varpropto:"∝",vArr:"⇕",varr:"↕",varrho:"ϱ",varsigma:"ς",varsubsetneq:"⊊︀",varsubsetneqq:"⫋︀",varsupsetneq:"⊋︀",varsupsetneqq:"⫌︀",vartheta:"ϑ",vartriangleleft:"⊲",vartriangleright:"⊳",Vbar:"⫫",vBar:"⫨",vBarv:"⫩",Vcy:"В",vcy:"в",VDash:"⊫",Vdash:"⊩",vDash:"⊨",vdash:"⊢",Vdashl:"⫦",Vee:"⋁",vee:"∨",veebar:"⊻",veeeq:"≚",vellip:"⋮",Verbar:"‖",verbar:"|",Vert:"‖",vert:"|",VerticalBar:"∣",VerticalLine:"|",VerticalSeparator:"❘",VerticalTilde:"≀",VeryThinSpace:" ",Vfr:"𝔙",vfr:"𝔳",vltri:"⊲",vnsub:"⊂⃒",vnsup:"⊃⃒",Vopf:"𝕍",vopf:"𝕧",vprop:"∝",vrtri:"⊳",Vscr:"𝒱",vscr:"𝓋",vsubnE:"⫋︀",vsubne:"⊊︀",vsupnE:"⫌︀",vsupne:"⊋︀",Vvdash:"⊪",vzigzag:"⦚",Wcirc:"Ŵ",wcirc:"ŵ",wedbar:"⩟",Wedge:"⋀",wedge:"∧",wedgeq:"≙",weierp:"℘",Wfr:"𝔚",wfr:"𝔴",Wopf:"𝕎",wopf:"𝕨",wp:"℘",wr:"≀",wreath:"≀",Wscr:"𝒲",wscr:"𝓌",xcap:"⋂",xcirc:"◯",xcup:"⋃",xdtri:"▽",Xfr:"𝔛",xfr:"𝔵",xhArr:"⟺",xharr:"⟷",Xi:"Ξ",xi:"ξ",xlArr:"⟸",xlarr:"⟵",xmap:"⟼",xnis:"⋻",xodot:"⨀",Xopf:"𝕏",xopf:"𝕩",xoplus:"⨁",xotime:"⨂",xrArr:"⟹",xrarr:"⟶",Xscr:"𝒳",xscr:"𝓍",xsqcup:"⨆",xuplus:"⨄",xutri:"△",xvee:"⋁",xwedge:"⋀",Yacute:"Ý",yacute:"ý",YAcy:"Я",yacy:"я",Ycirc:"Ŷ",ycirc:"ŷ",Ycy:"Ы",ycy:"ы",yen:"¥",Yfr:"𝔜",yfr:"𝔶",YIcy:"Ї",yicy:"ї",Yopf:"𝕐",yopf:"𝕪",Yscr:"𝒴",yscr:"𝓎",YUcy:"Ю",yucy:"ю",Yuml:"Ÿ",yuml:"ÿ",Zacute:"Ź",zacute:"ź",Zcaron:"Ž",zcaron:"ž",Zcy:"З",zcy:"з",Zdot:"Ż",zdot:"ż",zeetrf:"ℨ",ZeroWidthSpace:"​",Zeta:"Ζ",zeta:"ζ",Zfr:"ℨ",zfr:"𝔷",ZHcy:"Ж",zhcy:"ж",zigrarr:"⇝",Zopf:"ℤ",zopf:"𝕫",Zscr:"𝒵",zscr:"𝓏",zwj:"‍",zwnj:"‌"}},function(e,t,n){"use strict";var r=n(418),o=n(27).unescapeMd;e.exports=function(e,t){var n,i,a,u=t,s=e.posMax;if(60===e.src.charCodeAt(t)){for(t++;t8&&n<14);)if(92===n&&t+11)break;if(41===n&&--i<0)break;t++}return u!==t&&(a=o(e.src.slice(u,t)),!!e.parser.validateLink(a)&&(e.linkContent=a,e.pos=t,!0))}},function(e,t,n){"use strict";var r=n(27).replaceEntities;e.exports=function(e){var t=r(e);try{t=decodeURI(t)}catch(e){}return encodeURI(t)}},function(e,t,n){"use strict";var r=n(27).unescapeMd;e.exports=function(e,t){var n,o=t,i=e.posMax,a=e.src.charCodeAt(t);if(34!==a&&39!==a&&40!==a)return!1;for(t++,40===a&&(a=41);t1?r-1:0),i=1;i1?t-1:0),r=1;r0?Array(e+1).join(" ")+t:t}).join("\n")}(0,(0,r.default)(a,null,2))||"{}",c.default.createElement("br",null)))}}]),t}(l.Component);t.default=p},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=c(n(4)),o=c(n(2)),i=c(n(3)),a=c(n(5)),u=c(n(6)),s=c(n(0)),l=n(7);c(n(1)),c(n(11));function c(e){return e&&e.__esModule?e:{default:e}}var f=function(e){function t(){var e,n,i,u;(0,o.default)(this,t);for(var s=arguments.length,l=Array(s),c=0;c=e.length?(this._t=void 0,o(1)):o(0,"keys"==t?n:"values"==t?e[n]:[n,e[n]])},"values"),i.Arguments=i.Array,r("keys"),r("values"),r("entries")},function(e,t){e.exports=function(){}},function(e,t){e.exports=function(e,t){return{value:t,done:!!e}}},function(e,t,n){"use strict";var r=n(159),o=n(96),i=n(98),a={};n(50)(a,n(20)("iterator"),function(){return this}),e.exports=function(e,t,n){e.prototype=r(a,{next:o(1,n)}),i(e,t+" Iterator")}},function(e,t,n){var r=n(39),o=n(35),i=n(97);e.exports=n(44)?Object.defineProperties:function(e,t){o(e);for(var n,a=i(t),u=a.length,s=0;u>s;)r.f(e,n=a[s++],t[n]);return e}},function(e,t,n){var r=n(71),o=n(115),i=n(454);e.exports=function(e){return function(t,n,a){var u,s=r(t),l=o(s.length),c=i(a,l);if(e&&n!=n){for(;l>c;)if((u=s[c++])!=u)return!0}else for(;l>c;c++)if((e||c in s)&&s[c]===n)return e||c||0;return!e&&-1}}},function(e,t,n){var r=n(160),o=Math.max,i=Math.min;e.exports=function(e,t){return(e=r(e))<0?o(e+t,0):i(e,t)}},function(e,t,n){var r=n(160),o=n(155);e.exports=function(e){return function(t,n){var i,a,u=String(o(t)),s=r(n),l=u.length;return s<0||s>=l?e?"":void 0:(i=u.charCodeAt(s))<55296||i>56319||s+1===l||(a=u.charCodeAt(s+1))<56320||a>57343?e?u.charAt(s):i:e?u.slice(s,s+2):a-56320+(i-55296<<10)+65536}}},function(e,t,n){var r=n(35),o=n(164);e.exports=n(14).getIterator=function(e){var t=o(e);if("function"!=typeof t)throw TypeError(e+" is not iterable!");return r(t.call(e))}},function(e,t,n){n(458),n(245),n(469),n(473),n(485),n(486),e.exports=n(53).Promise},function(e,t,n){"use strict";var r=n(166),o={};o[n(17)("toStringTag")]="z",o+""!="[object z]"&&n(73)(Object.prototype,"toString",function(){return"[object "+r(this)+"]"},!0)},function(e,t,n){e.exports=!n(101)&&!n(102)(function(){return 7!=Object.defineProperty(n(169)("div"),"a",{get:function(){return 7}}).a})},function(e,t,n){var r=n(74);e.exports=function(e,t){if(!r(e))return e;var n,o;if(t&&"function"==typeof(n=e.toString)&&!r(o=n.call(e)))return o;if("function"==typeof(n=e.valueOf)&&!r(o=n.call(e)))return o;if(!t&&"function"==typeof(n=e.toString)&&!r(o=n.call(e)))return o;throw TypeError("Can't convert object to primitive value")}},function(e,t,n){"use strict";var r=n(462),o=n(244),i=n(171),a={};n(59)(a,n(17)("iterator"),function(){return this}),e.exports=function(e,t,n){e.prototype=r(a,{next:o(1,n)}),i(e,t+" Iterator")}},function(e,t,n){var r=n(60),o=n(463),i=n(250),a=n(170)("IE_PROTO"),u=function(){},s=function(){var e,t=n(169)("iframe"),r=i.length;for(t.style.display="none",n(251).appendChild(t),t.src="javascript:",(e=t.contentWindow.document).open(),e.write(" + + + + + + + + + + + + + + + + +
 
+
+ + diff --git a/core-rest/src/main/resources/swagger-ui/lib/backbone-min.js b/core-rest/src/main/resources/swagger-ui/lib/backbone-min.js new file mode 100644 index 0000000..a3f544b --- /dev/null +++ b/core-rest/src/main/resources/swagger-ui/lib/backbone-min.js @@ -0,0 +1,15 @@ +// Backbone.js 1.1.2 + +(function(t,e){if(typeof define==="function"&&define.amd){define(["underscore","jquery","exports"],function(i,r,s){t.Backbone=e(t,s,i,r)})}else if(typeof exports!=="undefined"){var i=require("underscore");e(t,exports,i)}else{t.Backbone=e(t,{},t._,t.jQuery||t.Zepto||t.ender||t.$)}})(this,function(t,e,i,r){var s=t.Backbone;var n=[];var a=n.push;var o=n.slice;var h=n.splice;e.VERSION="1.1.2";e.$=r;e.noConflict=function(){t.Backbone=s;return this};e.emulateHTTP=false;e.emulateJSON=false;var u=e.Events={on:function(t,e,i){if(!c(this,"on",t,[e,i])||!e)return this;this._events||(this._events={});var r=this._events[t]||(this._events[t]=[]);r.push({callback:e,context:i,ctx:i||this});return this},once:function(t,e,r){if(!c(this,"once",t,[e,r])||!e)return this;var s=this;var n=i.once(function(){s.off(t,n);e.apply(this,arguments)});n._callback=e;return this.on(t,n,r)},off:function(t,e,r){var s,n,a,o,h,u,l,f;if(!this._events||!c(this,"off",t,[e,r]))return this;if(!t&&!e&&!r){this._events=void 0;return this}o=t?[t]:i.keys(this._events);for(h=0,u=o.length;h").attr(t);this.setElement(r,false)}else{this.setElement(i.result(this,"el"),false)}}});e.sync=function(t,r,s){var n=T[t];i.defaults(s||(s={}),{emulateHTTP:e.emulateHTTP,emulateJSON:e.emulateJSON});var a={type:n,dataType:"json"};if(!s.url){a.url=i.result(r,"url")||M()}if(s.data==null&&r&&(t==="create"||t==="update"||t==="patch")){a.contentType="application/json";a.data=JSON.stringify(s.attrs||r.toJSON(s))}if(s.emulateJSON){a.contentType="application/x-www-form-urlencoded";a.data=a.data?{model:a.data}:{}}if(s.emulateHTTP&&(n==="PUT"||n==="DELETE"||n==="PATCH")){a.type="POST";if(s.emulateJSON)a.data._method=n;var o=s.beforeSend;s.beforeSend=function(t){t.setRequestHeader("X-HTTP-Method-Override",n);if(o)return o.apply(this,arguments)}}if(a.type!=="GET"&&!s.emulateJSON){a.processData=false}if(a.type==="PATCH"&&k){a.xhr=function(){return new ActiveXObject("Microsoft.XMLHTTP")}}var h=s.xhr=e.ajax(i.extend(a,s));r.trigger("request",r,h,s);return h};var k=typeof window!=="undefined"&&!!window.ActiveXObject&&!(window.XMLHttpRequest&&(new XMLHttpRequest).dispatchEvent);var T={create:"POST",update:"PUT",patch:"PATCH","delete":"DELETE",read:"GET"};e.ajax=function(){return e.$.ajax.apply(e.$,arguments)};var $=e.Router=function(t){t||(t={});if(t.routes)this.routes=t.routes;this._bindRoutes();this.initialize.apply(this,arguments)};var S=/\((.*?)\)/g;var H=/(\(\?)?:\w+/g;var A=/\*\w+/g;var I=/[\-{}\[\]+?.,\\\^$|#\s]/g;i.extend($.prototype,u,{initialize:function(){},route:function(t,r,s){if(!i.isRegExp(t))t=this._routeToRegExp(t);if(i.isFunction(r)){s=r;r=""}if(!s)s=this[r];var n=this;e.history.route(t,function(i){var a=n._extractParameters(t,i);n.execute(s,a);n.trigger.apply(n,["route:"+r].concat(a));n.trigger("route",r,a);e.history.trigger("route",n,r,a)});return this},execute:function(t,e){if(t)t.apply(this,e)},navigate:function(t,i){e.history.navigate(t,i);return this},_bindRoutes:function(){if(!this.routes)return;this.routes=i.result(this,"routes");var t,e=i.keys(this.routes);while((t=e.pop())!=null){this.route(t,this.routes[t])}},_routeToRegExp:function(t){t=t.replace(I,"\\$&").replace(S,"(?:$1)?").replace(H,function(t,e){return e?t:"([^/?]+)"}).replace(A,"([^?]*?)");return new RegExp("^"+t+"(?:\\?([\\s\\S]*))?$")},_extractParameters:function(t,e){var r=t.exec(e).slice(1);return i.map(r,function(t,e){if(e===r.length-1)return t||null;return t?decodeURIComponent(t):null})}});var N=e.History=function(){this.handlers=[];i.bindAll(this,"checkUrl");if(typeof window!=="undefined"){this.location=window.location;this.history=window.history}};var R=/^[#\/]|\s+$/g;var O=/^\/+|\/+$/g;var P=/msie [\w.]+/;var C=/\/$/;var j=/#.*$/;N.started=false;i.extend(N.prototype,u,{interval:50,atRoot:function(){return this.location.pathname.replace(/[^\/]$/,"$&/")===this.root},getHash:function(t){var e=(t||this).location.href.match(/#(.*)$/);return e?e[1]:""},getFragment:function(t,e){if(t==null){if(this._hasPushState||!this._wantsHashChange||e){t=decodeURI(this.location.pathname+this.location.search);var i=this.root.replace(C,"");if(!t.indexOf(i))t=t.slice(i.length)}else{t=this.getHash()}}return t.replace(R,"")},start:function(t){if(N.started)throw new Error("Backbone.history has already been started");N.started=true;this.options=i.extend({root:"/"},this.options,t);this.root=this.options.root;this._wantsHashChange=this.options.hashChange!==false;this._wantsPushState=!!this.options.pushState;this._hasPushState=!!(this.options.pushState&&this.history&&this.history.pushState);var r=this.getFragment();var s=document.documentMode;var n=P.exec(navigator.userAgent.toLowerCase())&&(!s||s<=7);this.root=("/"+this.root+"/").replace(O,"/");if(n&&this._wantsHashChange){var a=e.$('