Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate frontend to use Tapir endpoints #28

Merged
merged 9 commits into from
Sep 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/src/main/scala/api.helpers.scala
Original file line number Diff line number Diff line change
@@ -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 =>
Expand Down
10 changes: 4 additions & 6 deletions app/src/main/scala/api.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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)

Expand Down
16 changes: 8 additions & 8 deletions app/src/main/scala/auth.scala
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
4 changes: 3 additions & 1 deletion app/src/main/scala/db.scala
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/scala/server.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
5 changes: 4 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -57,9 +57,12 @@ 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.retry" %%% "retry" % "0.3.5",
"com.github.japgolly.scalacss" %%% "core" % Versions.scalacss
)
)
.dependsOn(shared.js)

lazy val app =
project
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/main/scala/RetryingBackend.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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.*

case class Stability(
delay: FiniteDuration = 100.millis,
maxRetries: Int = 5
)

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)
}
Comment on lines +17 to +46
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MIgrating api.stability to Futures and to be used in sttp wasn't trivial, so I reimplemented an exponential backoff retrying backend using retry.
I left the original file in case we want to migrate it later.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine to remove it completely - though I would've imagined that sttp has a retying backend built-in given that both libraries are part of softwaremillverse :D

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be an interesting thing to try, actually! But as far as I know, there is no retry integration for sttp.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened an issue here


end RetryingBackend
183 changes: 65 additions & 118 deletions frontend/src/main/scala/api.client.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,152 +7,99 @@ 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 twotm8.frontend.Responses.ThoughtLeaderProfile
import org.scalajs.dom.RequestInit

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))

}
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))

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 register

def create(payload: Payloads.Create, token: Token) =
val req = new RequestInit {}
req.method = HttpMethod.POST
req.body = toJsonString(payload)
def login(payload: Payload.Login) = interpreter
.toClientThrowDecodeFailures(endpoints.login, None, backend)
.apply(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 create(payload: Payload.Create, token: Token) =
callEndpointWithPayloadAndToken(payload, endpoints.create_twot, token)

}
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
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
callEndpointWithPayloadAndToken(payload, endpoint, token)
end set_uwotm8

def set_follow(
payload: Payloads.Follow,
state: Boolean,
private def callEndpointWithPayloadAndToken[INPUT, OUTPUT](
payload: INPUT,
endpoint: Endpoint[JWT, INPUT, ErrorInfo, OUTPUT, 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 callEndpointWithPayloadAndToken

end ApiClient
Loading