From a72e0e5adc4f134d80889e2dddef9de8640895fb Mon Sep 17 00:00:00 2001 From: Lorenzo Gabriele Date: Sat, 10 Sep 2022 10:46:14 +0200 Subject: [PATCH 1/9] Factor out common "api" base in endpoints --- shared/src/main/scala/endpoints.scala | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/shared/src/main/scala/endpoints.scala b/shared/src/main/scala/endpoints.scala index f4df094..9d4ee0a 100644 --- a/shared/src/main/scala/endpoints.scala +++ b/shared/src/main/scala/endpoints.scala @@ -28,66 +28,66 @@ object endpoints: ) ) ) - ) + ).in("api") private val secureEndpoint = baseEndpoint .securityIn(auth.bearer[String]()) val get_me = secureEndpoint.get - .in("api" / "thought_leaders" / "me") + .in("thought_leaders" / "me") .out(jsonBody[ThoughtLeader]) val get_thought_leader = baseEndpoint .securityIn(auth.bearer[Option[String]]()) .get - .in("api" / "thought_leaders" / path[String]) + .in("thought_leaders" / path[String]) .out(jsonBody[ThoughtLeader]) val get_wall = secureEndpoint.get - .in("api" / "twots" / "wall") + .in("twots" / "wall") .out(jsonBody[Vector[Twot]]) val get_health = baseEndpoint.get - .in("api" / "health") + .in("health") .out(jsonBody[Health]) val login = baseEndpoint.post - .in("api" / "auth" / "login") + .in("auth" / "login") .in(jsonBody[Payload.Login]) .out(jsonBody[Token]) val create_twot = secureEndpoint.post - .in("api" / "twots" / "create") + .in("twots" / "create") .in(jsonBody[Payload.Create]) .out(statusCode(StatusCode.NoContent)) val register = baseEndpoint.put - .in("api" / "auth" / "register") + .in("auth" / "register") .in(jsonBody[Payload.Register]) .out(statusCode(StatusCode.NoContent)) val add_uwotm8 = secureEndpoint.put - .in("api" / "twots" / "uwotm8") + .in("twots" / "uwotm8") .in(jsonBody[Payload.Uwotm8]) .out(jsonBody[Uwotm8Status]) val add_follower = secureEndpoint.put - .in("api" / "thought_leaders" / "follow") + .in("thought_leaders" / "follow") .in(jsonBody[Payload.Follow]) .out(statusCode(StatusCode.NoContent)) val delete_follower = secureEndpoint.delete - .in("api" / "thought_leaders" / "follow") + .in("thought_leaders" / "follow") .in(jsonBody[Payload.Follow]) .out(statusCode(StatusCode.NoContent)) val delete_uwotm8 = secureEndpoint.delete - .in("api" / "twots" / "uwotm8") + .in("twots" / "uwotm8") .in(jsonBody[Payload.Uwotm8]) .out(jsonBody[Uwotm8Status]) val delete_twot = secureEndpoint.delete - .in("api" / "twots" / path[UUID]) + .in("twots" / path[UUID]) .out(statusCode(StatusCode.NoContent)) end endpoints From 4ca2386186cb589ae2500ca1bb41224489a5090a Mon Sep 17 00:00:00 2001 From: Lorenzo Gabriele Date: Sat, 10 Sep 2022 10:46:37 +0200 Subject: [PATCH 2/9] Bump Tapir to 1.1.0 --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index eaa47d2..08f934d 100644 --- a/build.sbt +++ b/build.sbt @@ -9,7 +9,7 @@ Global / onChangedBuildSource := ReloadOnSourceChanges val Versions = new { val Scala = "3.2.0" val SNUnit = "0.0.24" - val Tapir = "1.0.6" + val Tapir = "1.1.0" val upickle = "2.0.0" val scribe = "3.10.3" val Laminar = "0.14.2" From 651e0d1aea3d0c5fa92e15c69fa6ceadc99ec6b7 Mon Sep 17 00:00:00 2001 From: Lorenzo Gabriele Date: Sat, 10 Sep 2022 10:46:57 +0200 Subject: [PATCH 3/9] Add Tapir and dependency to shared module on frontend --- build.sbt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.sbt b/build.sbt index 08f934d..e6e75cc 100644 --- a/build.sbt +++ b/build.sbt @@ -57,9 +57,11 @@ lazy val frontend = "org.scala-js" %%% "scalajs-dom" % Versions.scalajsDom, "com.raquo" %%% "waypoint" % Versions.waypoint, "com.lihaoyi" %%% "upickle" % Versions.upickle, + "com.softwaremill.sttp.tapir" %% "tapir-sttp-client" % Versions.Tapir, "com.github.japgolly.scalacss" %%% "core" % Versions.scalacss ) ) + .dependsOn(shared.js) lazy val app = project From b77f822c4f8939c40b2988f4c1b3dd3be796614d Mon Sep 17 00:00:00 2001 From: Lorenzo Gabriele Date: Sun, 11 Sep 2022 10:18:01 +0200 Subject: [PATCH 4/9] Migrate frontend to use tapir endpoints --- app/src/main/scala/api.scala | 8 +- build.sbt | 5 +- frontend/src/main/scala/RetryingBackend.scala | 42 ++++ frontend/src/main/scala/api.client.scala | 189 +++++++----------- frontend/src/main/scala/api.models.scala | 63 ------ frontend/src/main/scala/app.state.scala | 12 +- .../src/main/scala/views/CreateTwot.scala | 3 +- frontend/src/main/scala/views/TwotCard.scala | 17 +- frontend/src/main/scala/views/login.scala | 5 +- frontend/src/main/scala/views/profile.scala | 11 +- frontend/src/main/scala/views/register.scala | 3 +- frontend/src/main/scala/views/wall.scala | 2 +- shared/src/main/scala/ErrorInfo.scala | 3 +- shared/src/main/scala/endpoints.scala | 6 +- shared/src/main/scala/json.scala | 5 + 15 files changed, 160 insertions(+), 214 deletions(-) create mode 100644 frontend/src/main/scala/RetryingBackend.scala delete mode 100644 frontend/src/main/scala/api.models.scala diff --git a/app/src/main/scala/api.scala b/app/src/main/scala/api.scala index be675e2..cefb0bc 100644 --- a/app/src/main/scala/api.scala +++ b/app/src/main/scala/api.scala @@ -50,9 +50,8 @@ class Api(app: App): .serverLogicSuccess(delete_twot) ) - private def validateBearer(bearer: String): Either[ErrorInfo, AuthContext] = - val jwt = JWT(bearer) - app.validate(jwt) match + private def validateBearer(bearer: JWT): Either[ErrorInfo, AuthContext] = + app.validate(bearer) match case None => Left(ErrorInfo.Unauthorized()) case Some(auth) => @@ -115,9 +114,8 @@ class Api(app: App): end if end create_twot - private def delete_twot(auth: AuthContext)(uuid: UUID): Unit = + private def delete_twot(auth: AuthContext)(twotId: TwotId): Unit = val authorId = auth.author - val twotId = TwotId(uuid) app.delete_twot(authorId, twotId) diff --git a/build.sbt b/build.sbt index e6e75cc..7fe6ddb 100644 --- a/build.sbt +++ b/build.sbt @@ -57,7 +57,8 @@ lazy val frontend = "org.scala-js" %%% "scalajs-dom" % Versions.scalajsDom, "com.raquo" %%% "waypoint" % Versions.waypoint, "com.lihaoyi" %%% "upickle" % Versions.upickle, - "com.softwaremill.sttp.tapir" %% "tapir-sttp-client" % Versions.Tapir, + "com.softwaremill.sttp.tapir" %%% "tapir-sttp-client" % Versions.Tapir, + "com.softwaremill.retry" %%% "retry" % "0.3.5", "com.github.japgolly.scalacss" %%% "core" % Versions.scalacss ) ) @@ -72,7 +73,7 @@ lazy val app = .settings(vcpkgNativeConfig()) .settings( scalaVersion := Versions.Scala, - vcpkgDependencies := Set("libpq", "openssl", "libidn2"), + vcpkgDependencies := Set("libpq", "openssl"), libraryDependencies += "com.softwaremill.sttp.model" %%% "core" % "1.5.2", libraryDependencies += "com.outr" %%% "scribe" % Versions.scribe, libraryDependencies += "com.lihaoyi" %%% "upickle" % Versions.upickle, diff --git a/frontend/src/main/scala/RetryingBackend.scala b/frontend/src/main/scala/RetryingBackend.scala new file mode 100644 index 0000000..93bb449 --- /dev/null +++ b/frontend/src/main/scala/RetryingBackend.scala @@ -0,0 +1,42 @@ +package twotm8 +package frontend + +import sttp.capabilities.Effect +import sttp.client3.* +import sttp.model.Method +import scala.concurrent.Future +import scala.concurrent.duration.* +import scalacss.internal.LengthUnit.ex + +class RetryingBackend[P]( + delegate: SttpBackend[Future, P] +)(using stability: Stability) + extends DelegateSttpBackend[Future, P](delegate): + import scala.concurrent.ExecutionContext.Implicits.global + + private given retry.Success[ + (Request[?, ?], Either[Throwable, Response[?]]) + ]((req, res) => !RetryWhen.Default(req, res)) + + // The default timer is currently broken in JS + // https://github.com/softwaremill/odelay/pull/19 + private given odelay.Timer = odelay.js.JsTimer.newTimer + + override def send[T, R >: P & Effect[Future]]( + request: Request[T, R] + ): Future[Response[T]] = + retry + .Backoff(stability.maxRetries, stability.delay) + .apply { + delegate + .send(request) + .transform(tryResponse => + scala.util.Success((request, tryResponse.toEither)) + ) + } + .flatMap { + case (_, Right(response)) => Future.successful(response) + case (_, Left(exception)) => Future.failed(exception) + } + +end RetryingBackend diff --git a/frontend/src/main/scala/api.client.scala b/frontend/src/main/scala/api.client.scala index f77fb8d..4125683 100644 --- a/frontend/src/main/scala/api.client.scala +++ b/frontend/src/main/scala/api.client.scala @@ -9,150 +9,105 @@ import org.scalajs.dom import org.scalajs.dom.Fetch.fetch import org.scalajs.dom.* import org.scalajs.dom.experimental.ResponseInit +import twotm8.endpoints.* import upickle.default.ReadWriter import scala.concurrent.Future import scala.scalajs.js import scala.scalajs.js.JSON import scala.scalajs.js.Promise -import twotm8.frontend.Responses.ThoughtLeaderProfile +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.client3.SttpBackend import org.scalajs.dom.RequestInit +import sttp.tapir.Endpoint.apply +import sttp.tapir.Endpoint +import twotm8.api.ErrorInfo +import twotm8.api.ErrorInfo.Unauthorized +import twotm8.api.Payload +import sttp.client3.FetchBackend +import twotm8.frontend.RetryingBackend object ApiClient extends ApiClient(using Stability()) class ApiClient(using Stability): - import scala.scalajs.js - import js.Thenable.Implicits.* import scala.concurrent.ExecutionContext.Implicits.global - extension (req: Future[Response]) - def authenticated[A](f: Response => Future[A]): Future[Either[Error, A]] = - req.flatMap { resp => - if resp.status == 401 then Future.successful(Left(Error.Unauthorized)) - else f(resp).map(Right.apply) - } + private val backend = RetryingBackend(FetchBackend()) + private val interpreter = SttpClientInterpreter() - def get_profile( - author: String, - token: Option[Token] - ): Future[Responses.ThoughtLeaderProfile] = - exponentialFetch( - s"/api/thought_leaders/$author", - addAuth(new RequestInit {}, token) - ).flatMap { resp => - resp - .json() - .map(fromJson[Responses.ThoughtLeaderProfile]) - } - - def me(tok: Token): Future[Either[Error, ThoughtLeaderProfile]] = - val req = new RequestInit {} - req.method = HttpMethod.GET - exponentialFetch("/api/thought_leaders/me", addAuth(req, tok)) - .authenticated { resp => - resp.json().map(fromJson[Responses.ThoughtLeaderProfile]) - } + def get_profile(author: String, token: Option[Token]) = interpreter + .toSecureClientThrowErrors(endpoints.get_thought_leader, None, backend) + .apply(token.map(_.value)) + .apply(author) - def is_authenticated(token: Token): Future[Boolean] = - me(token).map(_.isRight) + def me(token: Token) = + interpreter + .toSecureClientThrowDecodeFailures(endpoints.get_me, None, backend) + .apply(token.value) + .apply(()) def get_wall(token: Token) = - exponentialFetch("/api/twots/wall", addAuth(new RequestInit {}, token)) - .authenticated { resp => - resp.json().map(fromJson[List[Responses.Twot]]) - } - - private def addAuth(rq: RequestInit, tok: Token) = - rq.headers = js.Dictionary("Authorization" -> s"Bearer ${tok.value}") - rq - - private def addAuth(rq: RequestInit, tokenMaybe: Option[Token]) = - tokenMaybe.foreach { tok => - rq.headers = js.Dictionary("Authorization" -> s"Bearer ${tok.value}") - } - rq - - def login( - payload: Payloads.Login - ): Future[Either[String, Responses.TokenResponse]] = - val req = new RequestInit {} - req.method = HttpMethod.POST - req.body = toJsonString(payload) - - exponentialFetch(s"/api/auth/login", req, forceRetry = true) - .flatMap { resp => - if resp.ok then - resp.json().map(fromJson[Responses.TokenResponse]).map(Right.apply) - else resp.text().map(txt => Left(txt)) - + interpreter + .toSecureClientThrowDecodeFailures(endpoints.get_wall, None, backend) + .apply(token.value) + .apply(()) + + def register(payload: Payload.Register) = + interpreter + .toClientThrowDecodeFailures(endpoints.register, None, backend) + .apply(payload) + .map { + case Right(_) => None + case Left(errorInfo) => Some(errorInfo.message) } - end login - - def register(payload: Payloads.Register): Future[Option[String]] = - val req = new RequestInit {} - req.method = HttpMethod.PUT - req.body = toJsonString(payload) - - exponentialFetch(s"/api/auth/register", req) - .flatMap { resp => - if resp.ok then Future.successful(None) - else resp.text().map(txt => Some(txt)) - - } - end register - - def create(payload: Payloads.Create, token: Token) = - val req = new RequestInit {} - req.method = HttpMethod.POST - req.body = toJsonString(payload) - - exponentialFetch(s"/api/twots/create", addAuth(req, token)) - .authenticated { resp => - if resp.ok then Future.successful(None) - else resp.text().map(txt => Some(txt)) + def login(payload: Payload.Login) = interpreter + .toClientThrowDecodeFailures(endpoints.login, None, backend) + .apply(payload) + + def create(payload: Payload.Create, token: Token) = + interpreter + .toSecureClientThrowDecodeFailures(endpoints.create_twot, None, backend) + .apply(token.value) + .apply(payload) + .map { + case Right(_) => Right(None) + case Left(Unauthorized(message)) => Left(Unauthorized(message)) + case Left(errorInfo) => Right(Some(errorInfo.message)) } - end create - - def delete_twot(id: String, token: Token) = - val req = new RequestInit {} - req.method = HttpMethod.DELETE - - exponentialFetch(s"/api/twots/$id", addAuth(req, token)) - .authenticated(resp => Future.successful(resp.ok)) - def set_uwotm8( - payload: Payloads.Uwotm8, - state: Boolean, - token: Token - ) = - val req = new RequestInit {} - req.method = if state then HttpMethod.PUT else HttpMethod.DELETE - req.body = toJsonString(payload) + def delete_twot(twotId: TwotId, token: Token) = + interpreter + .toSecureClientThrowDecodeFailures(endpoints.delete_twot, None, backend) + .apply(token.value) + .apply(twotId) - exponentialFetch(s"/api/twots/uwotm8", addAuth(req, token)) - .authenticated { resp => - if resp.ok then Future.successful(None) - else resp.text().map(txt => Some(txt)) + def set_follow(payload: Payload.Follow, state: Boolean, token: Token) = + val endpoint = + if state then endpoints.add_follower else endpoints.delete_follower + set(payload, endpoint, token) + end set_follow - } + def set_uwotm8(payload: Payload.Uwotm8, state: Boolean, token: Token) = + val endpoint = + if state then endpoints.add_uwotm8 else endpoints.delete_uwotm8 + set(payload, endpoint, token) end set_uwotm8 - def set_follow( - payload: Payloads.Follow, - state: Boolean, + private def set[T, U]( + payload: T, + endpoint: Endpoint[JWT, T, ErrorInfo, U, Any], token: Token ) = - val req = new RequestInit {} - req.method = if state then HttpMethod.PUT else HttpMethod.DELETE - req.body = toJsonString(payload) - - exponentialFetch(s"/api/thought_leaders/follow", addAuth(req, token)) - .authenticated { resp => - if resp.ok then Future.successful(None) - else resp.text().map(txt => Some(txt)) - + interpreter + .toSecureClientThrowDecodeFailures(endpoint, None, backend) + .apply(token.value) + .apply(payload) + .map { + case Right(_) => Right(None) + case Left(Unauthorized(message)) => Left(Unauthorized(message)) + case Left(errorInfo) => Right(Some(errorInfo.message)) } - end set_follow + end set end ApiClient diff --git a/frontend/src/main/scala/api.models.scala b/frontend/src/main/scala/api.models.scala deleted file mode 100644 index c0f4028..0000000 --- a/frontend/src/main/scala/api.models.scala +++ /dev/null @@ -1,63 +0,0 @@ -package twotm8 -package frontend - -import upickle.default.{ReadWriter, Reader, Writer, macroW, macroR} - -import scala.scalajs.js -import scala.scalajs.js.JSON - -def fromJson[T: Reader](a: js.Any) = upickle.default.read[T](JSON.stringify(a)) -def toJsonString[T: Writer](t: T) = upickle.default.write(t) - -enum Error: - case Unauthorized - -object Responses: - case class ThoughtLeaderProfile( - id: String, - nickname: String, - followers: List[String], - twots: Vector[Twot] - ) - object ThoughtLeaderProfile: - given Reader[ThoughtLeaderProfile] = macroR[ThoughtLeaderProfile] - - case class Twot( - id: String, - author: String, - authorNickname: String, - content: String, - uwotm8Count: Int, - uwotm8: Boolean - ) - object Twot: - given Reader[Twot] = macroR[Twot] - - case class TokenResponse(jwt: String, expiresIn: Long) - object TokenResponse: - given Reader[TokenResponse] = upickle.default.macroR[TokenResponse] -end Responses - -object Payloads: - - case class Register(nickname: String, password: String) - object Register: - given Writer[Register] = macroW[Register] - - case class Login(nickname: String, password: String) - object Login: - given Writer[Login] = macroW[Login] - - case class Create(text: String) - object Create: - given Writer[Create] = macroW[Create] - - case class Uwotm8(twot_id: String) - object Uwotm8: - given Writer[Uwotm8] = macroW[Uwotm8] - - case class Follow(thought_leader: String) - object Follow: - given Writer[Follow] = macroW[Follow] - -end Payloads diff --git a/frontend/src/main/scala/app.state.scala b/frontend/src/main/scala/app.state.scala index 760f878..0ec733f 100644 --- a/frontend/src/main/scala/app.state.scala +++ b/frontend/src/main/scala/app.state.scala @@ -4,7 +4,7 @@ package frontend import com.raquo.laminar.api.L.* import org.scalajs.dom -case class CachedProfile(id: String, nickname: String) +case class CachedProfile(id: AuthorId, nickname: Nickname) class AppState private ( _authToken: Var[Option[Token]], @@ -40,14 +40,16 @@ object AppState: private val tokenKey = "twotm8-auth-token" private def getToken(): Option[Token] = - Option(dom.window.localStorage.getItem(tokenKey)).map(Token.apply) + Option(dom.window.localStorage.getItem(tokenKey)).map(value => + Token(JWT(value)) + ) - private def setToken(value: String): Unit = - Option(dom.window.localStorage.setItem(tokenKey, value)) + private def setToken(value: JWT): Unit = + Option(dom.window.localStorage.setItem(tokenKey, value.raw)) private def deleteToken(): Unit = dom.window.localStorage.removeItem(tokenKey) end AppState -case class Token(value: String) +case class Token(value: JWT) diff --git a/frontend/src/main/scala/views/CreateTwot.scala b/frontend/src/main/scala/views/CreateTwot.scala index e1a1578..ca8f66d 100644 --- a/frontend/src/main/scala/views/CreateTwot.scala +++ b/frontend/src/main/scala/views/CreateTwot.scala @@ -8,6 +8,7 @@ import scala.concurrent.ExecutionContext.Implicits.global import scala.scalajs.js.Date import ApiClient.* +import twotm8.api.Payload def CreateTwot( update: () => Unit @@ -19,7 +20,7 @@ def CreateTwot( val sendTwot = onClick.preventDefault --> { _ => state.token.foreach { token => ApiClient - .create(Payloads.Create(text.now()), token) + .create(Payload.Create(Text(text.now())), token) .collect { case Right(e @ Some(err)) => error.set(e) case Right(None) => diff --git a/frontend/src/main/scala/views/TwotCard.scala b/frontend/src/main/scala/views/TwotCard.scala index 68c573f..db6a26a 100644 --- a/frontend/src/main/scala/views/TwotCard.scala +++ b/frontend/src/main/scala/views/TwotCard.scala @@ -5,11 +5,12 @@ import com.raquo.laminar.api.L.* import com.raquo.waypoint.Router import scala.concurrent.ExecutionContext.Implicits.global +import twotm8.api.Payload -def TwotCard(twot: Responses.Twot)(using Router[Page], AppState): HtmlElement = +def TwotCard(twot: Twot)(using Router[Page], AppState): HtmlElement = TwotCard(twot, () => ()) -def TwotCard(twot: Responses.Twot, update: () => Unit)(using +def TwotCard(twot: Twot, update: () => Unit)(using Router[Page] )(using state: AppState @@ -18,14 +19,14 @@ def TwotCard(twot: Responses.Twot, update: () => Unit)(using import scalajs.js.{Math as JSMath} val opacity = - (1.0 - 0.2 * JSMath.log(1 + twot.uwotm8Count) / JSMath.LOG2E) + (1.0 - 0.2 * JSMath.log(1 + twot.uwotm8Count.raw) / JSMath.LOG2E) val sendUwotm8 = onClick.preventDefault --> { _ => - val newState = !current.now() + val newState = Uwotm8Status(!current.now().raw) state.token.foreach { token => ApiClient - .set_uwotm8(Payloads.Uwotm8(twot.id), newState, token) + .set_uwotm8(Payload.Uwotm8(twot.id), newState.raw, token) .collect { case Right(None) => current.set(newState) case Right(Some(s)) => println("Shit." + s) @@ -58,21 +59,21 @@ def TwotCard(twot: Responses.Twot, update: () => Unit)(using Styles.twotTitle, a( Styles.profileLink, - navigateTo(Page.Profile(twot.authorNickname)), + navigateTo(Page.Profile(twot.authorNickname.raw)), "@" + twot.authorNickname ), child.maybe <-- deleteButton ), div( Styles.twotText, - twot.content + twot.content.raw ) ), div( Styles.twotUwotm8, button( sendUwotm8, - cls <-- current.signal.map(Styles.uwotm8Button(_).htmlClass), + cls <-- current.signal.map(v => Styles.uwotm8Button(v.raw).htmlClass), "UWOTM8" ) ) diff --git a/frontend/src/main/scala/views/login.scala b/frontend/src/main/scala/views/login.scala index f07ae51..fffa239 100644 --- a/frontend/src/main/scala/views/login.scala +++ b/frontend/src/main/scala/views/login.scala @@ -6,6 +6,7 @@ import com.raquo.laminar.api.L.* import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future +import twotm8.api.Payload def Login(using router: Router[Page], state: AppState): HtmlElement = val error = Var[Option[String]](None) @@ -38,11 +39,11 @@ private def LoginForm( val sendLogin = onClick.preventDefault --> { _ => ApiClient .login( - Payloads.Login(nickname = nickname.now(), password = password.now()) + Payload.Login(nickname = Nickname(nickname.now()), password = Password(password.now())) ) .foreach { case Left(err) => - error.set(Some(err)) + error.set(Some(err.message)) case Right(response) => val token = Token(response.jwt) state.setToken(token) diff --git a/frontend/src/main/scala/views/profile.scala b/frontend/src/main/scala/views/profile.scala index 99e49ef..8338bf7 100644 --- a/frontend/src/main/scala/views/profile.scala +++ b/frontend/src/main/scala/views/profile.scala @@ -9,14 +9,14 @@ import scala.concurrent.ExecutionContext.Implicits.global import FollowState.* import com.raquo.airstream.core.Signal -import twotm8.frontend.Responses.ThoughtLeaderProfile +import twotm8.api.Payload def Profile(page: Signal[Page.Profile])(using Router[Page])(using state: AppState ) = val followState = Var(FollowState.Hide) - val profile: Signal[Option[ThoughtLeaderProfile]] = + val profile: Signal[Option[ThoughtLeader]] = page .map(_.authorId) .combineWith(state.$token) @@ -31,7 +31,8 @@ def Profile(page: Signal[Page.Profile])(using Router[Page])(using .map { case (followers, currentUser) => currentUser .map { prof => - if (followers.contains(prof.id)) then Yes else No + if (followers.exists(follower => follower.raw == prof.id.raw)) then Yes + else No } .getOrElse(Hide) } @@ -72,7 +73,7 @@ enum FollowState derives UnivEq: private def FollowButton( followState: Var[FollowState], - thought_leader: Responses.ThoughtLeaderProfile + thought_leader: ThoughtLeader )(using state: AppState) = val sendFollow = onClick --> { _ => @@ -84,7 +85,7 @@ private def FollowButton( do ApiClient .set_follow( - Payloads.Follow(thought_leader.id), + Payload.Follow(thought_leader.id), newState == Yes, token ) diff --git a/frontend/src/main/scala/views/register.scala b/frontend/src/main/scala/views/register.scala index c79d36a..137bd68 100644 --- a/frontend/src/main/scala/views/register.scala +++ b/frontend/src/main/scala/views/register.scala @@ -5,6 +5,7 @@ import com.raquo.laminar.api.L.* import com.raquo.waypoint.Router import scala.concurrent.ExecutionContext.Implicits.global +import twotm8.api.Payload def Register(using router: Router[Page], state: AppState): HtmlElement = val error = Var[Option[String]](None) @@ -36,7 +37,7 @@ private def RegisterForm(error: Var[Option[String]])(using val sendRegistration = onClick.preventDefault --> { _ => ApiClient .register( - Payloads.Register(nickname = nickname.now(), password = password.now()) + Payload.Register(nickname = Nickname(nickname.now()), password = Password(password.now())) ) .foreach { case e @ Some(err) => error.set(e) diff --git a/frontend/src/main/scala/views/wall.scala b/frontend/src/main/scala/views/wall.scala index 5871b7c..d7e0ae0 100644 --- a/frontend/src/main/scala/views/wall.scala +++ b/frontend/src/main/scala/views/wall.scala @@ -14,7 +14,7 @@ def Wall(using router: Router[Page], state: AppState): HtmlElement = h1( Styles.welcomeBanner, "Welcome back, ", - b(profile.nickname) + b(profile.nickname.raw) ) } } diff --git a/shared/src/main/scala/ErrorInfo.scala b/shared/src/main/scala/ErrorInfo.scala index dbe308e..86afe20 100644 --- a/shared/src/main/scala/ErrorInfo.scala +++ b/shared/src/main/scala/ErrorInfo.scala @@ -1,7 +1,8 @@ package twotm8 package api -sealed trait ErrorInfo +sealed trait ErrorInfo: + def message: String object ErrorInfo: case class NotFound(message: String = "Not Found") extends ErrorInfo case class BadRequest(message: String = "Bad Request") extends ErrorInfo diff --git a/shared/src/main/scala/endpoints.scala b/shared/src/main/scala/endpoints.scala index 9d4ee0a..ff1a910 100644 --- a/shared/src/main/scala/endpoints.scala +++ b/shared/src/main/scala/endpoints.scala @@ -31,14 +31,14 @@ object endpoints: ).in("api") private val secureEndpoint = baseEndpoint - .securityIn(auth.bearer[String]()) + .securityIn(auth.bearer[JWT]()) val get_me = secureEndpoint.get .in("thought_leaders" / "me") .out(jsonBody[ThoughtLeader]) val get_thought_leader = baseEndpoint - .securityIn(auth.bearer[Option[String]]()) + .securityIn(auth.bearer[Option[JWT]]()) .get .in("thought_leaders" / path[String]) .out(jsonBody[ThoughtLeader]) @@ -87,7 +87,7 @@ object endpoints: .out(jsonBody[Uwotm8Status]) val delete_twot = secureEndpoint.delete - .in("twots" / path[UUID]) + .in("twots" / path[TwotId]) .out(statusCode(StatusCode.NoContent)) end endpoints diff --git a/shared/src/main/scala/json.scala b/shared/src/main/scala/json.scala index b1898dc..f0fba34 100644 --- a/shared/src/main/scala/json.scala +++ b/shared/src/main/scala/json.scala @@ -41,6 +41,11 @@ object codecs: given ReadWriter[api.Payload.Follow] = upickle.default.macroRW[api.Payload.Follow] + given Codec.PlainCodec[TwotId] = + Codec.uuid.map(TwotId(_))(_.raw) + given Codec.PlainCodec[JWT] = + Codec.string.map(JWT(_))(_.raw) + given Codec.PlainCodec[api.ErrorInfo.NotFound] = Codec.string.map(api.ErrorInfo.NotFound(_))(_.message) given Codec.PlainCodec[api.ErrorInfo.BadRequest] = From 6dd2ae5a8d8acc525d5a976b05c217594ef5e896 Mon Sep 17 00:00:00 2001 From: Lorenzo Gabriele Date: Sun, 11 Sep 2022 10:24:40 +0200 Subject: [PATCH 5/9] Organize imports --- app/src/main/scala/api.helpers.scala | 5 +++-- app/src/main/scala/api.scala | 2 +- app/src/main/scala/auth.scala | 16 +++++++-------- app/src/main/scala/db.scala | 4 +++- app/src/main/scala/server.scala | 2 +- frontend/src/main/scala/RetryingBackend.scala | 3 ++- frontend/src/main/scala/api.client.scala | 20 +++++++++---------- frontend/src/main/scala/api.stability.scala | 2 +- frontend/src/main/scala/styles.scala | 2 +- .../src/main/scala/views/CreateTwot.scala | 2 +- frontend/src/main/scala/views/TwotCard.scala | 2 +- frontend/src/main/scala/views/login.scala | 4 ++-- frontend/src/main/scala/views/profile.scala | 4 ++-- frontend/src/main/scala/views/register.scala | 2 +- frontend/src/main/scala/views/wall.scala | 2 +- 15 files changed, 38 insertions(+), 34 deletions(-) diff --git a/app/src/main/scala/api.helpers.scala b/app/src/main/scala/api.helpers.scala index ad2e9a3..8fcdaeb 100644 --- a/app/src/main/scala/api.helpers.scala +++ b/app/src/main/scala/api.helpers.scala @@ -1,10 +1,11 @@ package twotm8 import snunit.* -import scala.util.Try + +import java.util.UUID import scala.util.Failure import scala.util.Success -import java.util.UUID +import scala.util.Try trait ApiHelpers: inline def handleException(inline handler: Handler): Handler = req => diff --git a/app/src/main/scala/api.scala b/app/src/main/scala/api.scala index cefb0bc..e65f98f 100644 --- a/app/src/main/scala/api.scala +++ b/app/src/main/scala/api.scala @@ -4,8 +4,8 @@ package api import roach.RoachException import roach.RoachFatalException import snunit.tapir.SNUnitInterpreter.* -import sttp.tapir.server.* import sttp.tapir.* +import sttp.tapir.server.* import java.util.UUID diff --git a/app/src/main/scala/auth.scala b/app/src/main/scala/auth.scala index 60f9457..c0d6986 100644 --- a/app/src/main/scala/auth.scala +++ b/app/src/main/scala/auth.scala @@ -1,19 +1,19 @@ package twotm8 -import scala.scalanative.unsafe.* -import scala.scalanative.libc.* - -import scala.util.Using -import java.util.UUID +import openssl.OpenSSL import openssl.functions.* import openssl.types.* + import java.time.Instant -import scala.concurrent.duration.* -import scala.scalanative.posix.time import java.util.Base64 +import java.util.UUID import java.{util as ju} +import scala.concurrent.duration.* +import scala.scalanative.libc.* +import scala.scalanative.posix.time +import scala.scalanative.unsafe.* import scala.util.Try -import openssl.OpenSSL +import scala.util.Using object Auth: def validate( diff --git a/app/src/main/scala/db.scala b/app/src/main/scala/db.scala index 7fb2069..b522552 100644 --- a/app/src/main/scala/db.scala +++ b/app/src/main/scala/db.scala @@ -1,7 +1,9 @@ package twotm8 package db -import roach.{Database, Codec} +import roach.Codec +import roach.Database + import java.util.UUID import scala.scalanative.unsafe.* import scala.util.Using diff --git a/app/src/main/scala/server.scala b/app/src/main/scala/server.scala index 25f6153..8bb48c6 100644 --- a/app/src/main/scala/server.scala +++ b/app/src/main/scala/server.scala @@ -6,8 +6,8 @@ import scribe.format.Formatter import scribe.handler.LogHandler import scribe.writer.SystemErrWriter import snunit.* -import snunit.tapir.* import snunit.tapir.SNUnitInterpreter.* +import snunit.tapir.* import twotm8.db.DB import scala.concurrent.duration.* diff --git a/frontend/src/main/scala/RetryingBackend.scala b/frontend/src/main/scala/RetryingBackend.scala index 93bb449..3be1124 100644 --- a/frontend/src/main/scala/RetryingBackend.scala +++ b/frontend/src/main/scala/RetryingBackend.scala @@ -1,12 +1,13 @@ package twotm8 package frontend +import scalacss.internal.LengthUnit.ex import sttp.capabilities.Effect import sttp.client3.* import sttp.model.Method + import scala.concurrent.Future import scala.concurrent.duration.* -import scalacss.internal.LengthUnit.ex class RetryingBackend[P]( delegate: SttpBackend[Future, P] diff --git a/frontend/src/main/scala/api.client.scala b/frontend/src/main/scala/api.client.scala index 4125683..7927933 100644 --- a/frontend/src/main/scala/api.client.scala +++ b/frontend/src/main/scala/api.client.scala @@ -7,25 +7,25 @@ import com.raquo.laminar.api.L.* import com.raquo.waypoint.* import org.scalajs.dom import org.scalajs.dom.Fetch.fetch +import org.scalajs.dom.RequestInit import org.scalajs.dom.* import org.scalajs.dom.experimental.ResponseInit +import sttp.client3.FetchBackend +import sttp.client3.SttpBackend +import sttp.tapir.Endpoint +import sttp.tapir.Endpoint.apply +import sttp.tapir.client.sttp.SttpClientInterpreter +import twotm8.api.ErrorInfo +import twotm8.api.ErrorInfo.Unauthorized +import twotm8.api.Payload import twotm8.endpoints.* +import twotm8.frontend.RetryingBackend import upickle.default.ReadWriter import scala.concurrent.Future import scala.scalajs.js import scala.scalajs.js.JSON import scala.scalajs.js.Promise -import sttp.tapir.client.sttp.SttpClientInterpreter -import sttp.client3.SttpBackend -import org.scalajs.dom.RequestInit -import sttp.tapir.Endpoint.apply -import sttp.tapir.Endpoint -import twotm8.api.ErrorInfo -import twotm8.api.ErrorInfo.Unauthorized -import twotm8.api.Payload -import sttp.client3.FetchBackend -import twotm8.frontend.RetryingBackend object ApiClient extends ApiClient(using Stability()) diff --git a/frontend/src/main/scala/api.stability.scala b/frontend/src/main/scala/api.stability.scala index a4b69d4..6f3e64c 100644 --- a/frontend/src/main/scala/api.stability.scala +++ b/frontend/src/main/scala/api.stability.scala @@ -10,8 +10,8 @@ import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future import scala.concurrent.duration.* import scala.scalajs.js -import scala.scalajs.js.Thenable.Implicits.* import scala.scalajs.js.Date +import scala.scalajs.js.Thenable.Implicits.* case class Stability( initialDelay: FiniteDuration = 100.millis, diff --git a/frontend/src/main/scala/styles.scala b/frontend/src/main/scala/styles.scala index 93075fb..a38143d 100644 --- a/frontend/src/main/scala/styles.scala +++ b/frontend/src/main/scala/styles.scala @@ -2,8 +2,8 @@ package twotm8 package frontend import scalacss.ProdDefaults.* -import scalacss.internal.StyleA import scalacss.internal.Attrs.justifyItems +import scalacss.internal.StyleA object Styles extends StyleSheet.Inline: import dsl.* diff --git a/frontend/src/main/scala/views/CreateTwot.scala b/frontend/src/main/scala/views/CreateTwot.scala index ca8f66d..b80d52c 100644 --- a/frontend/src/main/scala/views/CreateTwot.scala +++ b/frontend/src/main/scala/views/CreateTwot.scala @@ -3,12 +3,12 @@ package frontend import com.raquo.laminar.api.L.* import com.raquo.waypoint.Router +import twotm8.api.Payload import scala.concurrent.ExecutionContext.Implicits.global import scala.scalajs.js.Date import ApiClient.* -import twotm8.api.Payload def CreateTwot( update: () => Unit diff --git a/frontend/src/main/scala/views/TwotCard.scala b/frontend/src/main/scala/views/TwotCard.scala index db6a26a..01ae822 100644 --- a/frontend/src/main/scala/views/TwotCard.scala +++ b/frontend/src/main/scala/views/TwotCard.scala @@ -3,9 +3,9 @@ package frontend import com.raquo.laminar.api.L.* import com.raquo.waypoint.Router +import twotm8.api.Payload import scala.concurrent.ExecutionContext.Implicits.global -import twotm8.api.Payload def TwotCard(twot: Twot)(using Router[Page], AppState): HtmlElement = TwotCard(twot, () => ()) diff --git a/frontend/src/main/scala/views/login.scala b/frontend/src/main/scala/views/login.scala index fffa239..cf9e98c 100644 --- a/frontend/src/main/scala/views/login.scala +++ b/frontend/src/main/scala/views/login.scala @@ -1,12 +1,12 @@ package twotm8 package frontend -import com.raquo.waypoint.Router import com.raquo.laminar.api.L.* +import com.raquo.waypoint.Router +import twotm8.api.Payload import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future -import twotm8.api.Payload def Login(using router: Router[Page], state: AppState): HtmlElement = val error = Var[Option[String]](None) diff --git a/frontend/src/main/scala/views/profile.scala b/frontend/src/main/scala/views/profile.scala index 8338bf7..675dcf5 100644 --- a/frontend/src/main/scala/views/profile.scala +++ b/frontend/src/main/scala/views/profile.scala @@ -1,15 +1,15 @@ package twotm8 package frontend +import com.raquo.airstream.core.Signal import com.raquo.laminar.api.L.* import com.raquo.waypoint.Router import japgolly.univeq.UnivEq +import twotm8.api.Payload import scala.concurrent.ExecutionContext.Implicits.global import FollowState.* -import com.raquo.airstream.core.Signal -import twotm8.api.Payload def Profile(page: Signal[Page.Profile])(using Router[Page])(using state: AppState diff --git a/frontend/src/main/scala/views/register.scala b/frontend/src/main/scala/views/register.scala index 137bd68..7bd6a8b 100644 --- a/frontend/src/main/scala/views/register.scala +++ b/frontend/src/main/scala/views/register.scala @@ -3,9 +3,9 @@ package frontend import com.raquo.laminar.api.L.* import com.raquo.waypoint.Router +import twotm8.api.Payload import scala.concurrent.ExecutionContext.Implicits.global -import twotm8.api.Payload def Register(using router: Router[Page], state: AppState): HtmlElement = val error = Var[Option[String]](None) diff --git a/frontend/src/main/scala/views/wall.scala b/frontend/src/main/scala/views/wall.scala index d7e0ae0..0b223ba 100644 --- a/frontend/src/main/scala/views/wall.scala +++ b/frontend/src/main/scala/views/wall.scala @@ -1,11 +1,11 @@ package twotm8 package frontend +import com.raquo.airstream.core.Signal import com.raquo.laminar.api.L.* import com.raquo.waypoint.Router import scala.scalajs.js.Date -import com.raquo.airstream.core.Signal def Wall(using router: Router[Page], state: AppState): HtmlElement = From 618eff9f659c241d876c86aa8720a556958363d3 Mon Sep 17 00:00:00 2001 From: Lorenzo Gabriele Date: Sun, 11 Sep 2022 10:25:00 +0200 Subject: [PATCH 6/9] Run Scalafmt --- frontend/src/main/scala/views/login.scala | 5 ++++- frontend/src/main/scala/views/profile.scala | 3 ++- frontend/src/main/scala/views/register.scala | 5 ++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/src/main/scala/views/login.scala b/frontend/src/main/scala/views/login.scala index cf9e98c..9a6d36e 100644 --- a/frontend/src/main/scala/views/login.scala +++ b/frontend/src/main/scala/views/login.scala @@ -39,7 +39,10 @@ private def LoginForm( val sendLogin = onClick.preventDefault --> { _ => ApiClient .login( - Payload.Login(nickname = Nickname(nickname.now()), password = Password(password.now())) + Payload.Login( + nickname = Nickname(nickname.now()), + password = Password(password.now()) + ) ) .foreach { case Left(err) => diff --git a/frontend/src/main/scala/views/profile.scala b/frontend/src/main/scala/views/profile.scala index 675dcf5..11b5af1 100644 --- a/frontend/src/main/scala/views/profile.scala +++ b/frontend/src/main/scala/views/profile.scala @@ -31,7 +31,8 @@ def Profile(page: Signal[Page.Profile])(using Router[Page])(using .map { case (followers, currentUser) => currentUser .map { prof => - if (followers.exists(follower => follower.raw == prof.id.raw)) then Yes + if (followers.exists(follower => follower.raw == prof.id.raw)) then + Yes else No } .getOrElse(Hide) diff --git a/frontend/src/main/scala/views/register.scala b/frontend/src/main/scala/views/register.scala index 7bd6a8b..e71275e 100644 --- a/frontend/src/main/scala/views/register.scala +++ b/frontend/src/main/scala/views/register.scala @@ -37,7 +37,10 @@ private def RegisterForm(error: Var[Option[String]])(using val sendRegistration = onClick.preventDefault --> { _ => ApiClient .register( - Payload.Register(nickname = Nickname(nickname.now()), password = Password(password.now())) + Payload.Register( + nickname = Nickname(nickname.now()), + password = Password(password.now()) + ) ) .foreach { case e @ Some(err) => error.set(e) From 075b63375073de04ef19b726fe2f42a2297c2f6e Mon Sep 17 00:00:00 2001 From: Lorenzo Gabriele Date: Sun, 11 Sep 2022 10:32:37 +0200 Subject: [PATCH 7/9] Reuse common logic in api.client --- frontend/src/main/scala/api.client.scala | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/frontend/src/main/scala/api.client.scala b/frontend/src/main/scala/api.client.scala index 7927933..2f8a236 100644 --- a/frontend/src/main/scala/api.client.scala +++ b/frontend/src/main/scala/api.client.scala @@ -66,15 +66,7 @@ class ApiClient(using Stability): .apply(payload) def create(payload: Payload.Create, token: Token) = - interpreter - .toSecureClientThrowDecodeFailures(endpoints.create_twot, None, backend) - .apply(token.value) - .apply(payload) - .map { - case Right(_) => Right(None) - case Left(Unauthorized(message)) => Left(Unauthorized(message)) - case Left(errorInfo) => Right(Some(errorInfo.message)) - } + callEndpointWithPayloadAndToken(payload, endpoints.create_twot, token) def delete_twot(twotId: TwotId, token: Token) = interpreter @@ -85,18 +77,18 @@ class ApiClient(using Stability): def set_follow(payload: Payload.Follow, state: Boolean, token: Token) = val endpoint = if state then endpoints.add_follower else endpoints.delete_follower - set(payload, endpoint, token) + callEndpointWithPayloadAndToken(payload, endpoint, token) end set_follow def set_uwotm8(payload: Payload.Uwotm8, state: Boolean, token: Token) = val endpoint = if state then endpoints.add_uwotm8 else endpoints.delete_uwotm8 - set(payload, endpoint, token) + callEndpointWithPayloadAndToken(payload, endpoint, token) end set_uwotm8 - private def set[T, U]( - payload: T, - endpoint: Endpoint[JWT, T, ErrorInfo, U, Any], + private def callEndpointWithPayloadAndToken[INPUT, OUTPUT]( + payload: INPUT, + endpoint: Endpoint[JWT, INPUT, ErrorInfo, OUTPUT, Any], token: Token ) = interpreter @@ -108,6 +100,6 @@ class ApiClient(using Stability): case Left(Unauthorized(message)) => Left(Unauthorized(message)) case Left(errorInfo) => Right(Some(errorInfo.message)) } - end set + end callEndpointWithPayloadAndToken end ApiClient From 9f0a9724872938c87c5de7760c588562b35462d1 Mon Sep 17 00:00:00 2001 From: Lorenzo Gabriele Date: Sun, 11 Sep 2022 13:43:50 +0200 Subject: [PATCH 8/9] Revert removal of libidn2 from vcpkgDependencies --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 7fe6ddb..4d488da 100644 --- a/build.sbt +++ b/build.sbt @@ -73,7 +73,7 @@ lazy val app = .settings(vcpkgNativeConfig()) .settings( scalaVersion := Versions.Scala, - vcpkgDependencies := Set("libpq", "openssl"), + vcpkgDependencies := Set("libpq", "openssl", "libidn2"), libraryDependencies += "com.softwaremill.sttp.model" %%% "core" % "1.5.2", libraryDependencies += "com.outr" %%% "scribe" % Versions.scribe, libraryDependencies += "com.lihaoyi" %%% "upickle" % Versions.upickle, From d226967a614bf78431b64f835dc8366df463ae9f Mon Sep 17 00:00:00 2001 From: Lorenzo Gabriele Date: Mon, 12 Sep 2022 10:52:24 +0200 Subject: [PATCH 9/9] Remove api.stabilty.scala and unused fields from Stability --- frontend/src/main/scala/RetryingBackend.scala | 5 ++ frontend/src/main/scala/api.stability.scala | 83 ------------------- 2 files changed, 5 insertions(+), 83 deletions(-) delete mode 100644 frontend/src/main/scala/api.stability.scala diff --git a/frontend/src/main/scala/RetryingBackend.scala b/frontend/src/main/scala/RetryingBackend.scala index 3be1124..bcc937c 100644 --- a/frontend/src/main/scala/RetryingBackend.scala +++ b/frontend/src/main/scala/RetryingBackend.scala @@ -9,6 +9,11 @@ import sttp.model.Method import scala.concurrent.Future import scala.concurrent.duration.* +case class Stability( + delay: FiniteDuration = 100.millis, + maxRetries: Int = 5 +) + class RetryingBackend[P]( delegate: SttpBackend[Future, P] )(using stability: Stability) diff --git a/frontend/src/main/scala/api.stability.scala b/frontend/src/main/scala/api.stability.scala deleted file mode 100644 index 6f3e64c..0000000 --- a/frontend/src/main/scala/api.stability.scala +++ /dev/null @@ -1,83 +0,0 @@ -package twotm8 -package frontend - -import org.scalajs.dom -import org.scalajs.dom.Fetch.fetch -import org.scalajs.dom.* -import org.scalajs.dom.experimental.ResponseInit - -import scala.concurrent.ExecutionContext.Implicits.global -import scala.concurrent.Future -import scala.concurrent.duration.* -import scala.scalajs.js -import scala.scalajs.js.Date -import scala.scalajs.js.Thenable.Implicits.* - -case class Stability( - initialDelay: FiniteDuration = 100.millis, - timeout: FiniteDuration = 500.millis, - delay: FiniteDuration = 100.millis, - maxRetries: Int = 5, - retryCodes: Set[Int] = Set(503, 500) -) - -def exponentialFetch( - info: RequestInfo, - req: RequestInit, - forceRetry: Boolean = false -)(using stability: Stability): Future[Response] = - import scalajs.js.Promise - import stability.* - - type Result = Either[String, Response] - - def go(attemptsRemaining: Int): Promise[Result] = - val nAttempt = maxRetries - attemptsRemaining - val newDelay: FiniteDuration = - if nAttempt == 0 then initialDelay - else (Math.pow(2.0, nAttempt) * delay.toMillis).millis - - if nAttempt != 0 then - dom.console.log( - s"Request to $info will be retried, $attemptsRemaining remaining, with delay $newDelay", - new Date() - ) - - def sleep(delay: FiniteDuration): Promise[Unit] = - Promise.apply((resolve, reject) => - dom.window.setTimeout(() => resolve(()), delay.toMillis) - ) - - def reqPromise: Promise[Result] = - fetch(info, req).`then`(resp => Right(resp)) - - val retryable = - forceRetry || req.method.getOrElse(HttpMethod.GET) != HttpMethod.POST - - if (attemptsRemaining == 0) then Promise.resolve(Left("no attempts left")) - else - Promise - .race(js.Array(reqPromise, sleep(timeout).`then`(_ => Left("timeout")))) - .`then` { - case Left(reason) => - if retryable then - sleep(newDelay).`then`(_ => go(attemptsRemaining - 1)) - else - Promise.reject( - s"Cannot retry the request to $info, reason: $reason" - ) - case r @ Right(res) => - if retryable && retryCodes.contains(res.status) then - sleep(newDelay).`then`(_ => go(attemptsRemaining - 1)) - else Promise.resolve(r) - } - end if - end go - - go(maxRetries).flatMap { - case Left(err) => - Promise.reject(s"Request to $info failed after all retries: $err") - case Right(value) => - Promise.resolve(value) - } -end exponentialFetch