Skip to content

Commit

Permalink
Add Flash, Flash.Message and Flash.Backend (#2481)
Browse files Browse the repository at this point in the history
* add new flash type and changed flash api

* fmt

* tiny stuff

* add optional flash!

* add Flash.Message

* change flash message, add flash backend

* bugfix for cookie path

* Update zio-http/src/main/scala/zio/http/Flash.scala

Co-authored-by: Jules Ivanic <jules.ivanic@gmail.com>

* Update zio-http/src/main/scala/zio/http/Flash.scala

Co-authored-by: Jules Ivanic <jules.ivanic@gmail.com>

* Update zio-http/src/main/scala/zio/http/Flash.scala

Co-authored-by: Jules Ivanic <jules.ivanic@gmail.com>

* changes from review

---------

Co-authored-by: Jules Ivanic <jules.ivanic@gmail.com>
Co-authored-by: John A. De Goes <john@degoes.net>
  • Loading branch information
3 people authored Dec 21, 2023
1 parent a74ffc0 commit f4b68d2
Show file tree
Hide file tree
Showing 5 changed files with 549 additions and 9 deletions.
397 changes: 397 additions & 0 deletions zio-http/src/main/scala/zio/http/Flash.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,397 @@
package zio.http

import java.net.{URLDecoder, URLEncoder}
import java.nio.charset.StandardCharsets
import java.util.UUID

import zio._

import zio.schema.Schema
import zio.schema.codec.JsonCodec

import zio.http.template._

/**
* `Flash` represents a flash value that one can retrieve from the flash scope.
*
* The flash scope consists of a serialized and url-encoded json object built
* with `zio-schema`.
*/
sealed trait Flash[+A] { self =>

final def flatMap[B](f: A => Flash[B]): Flash[B] = Flash.FlatMap(self, f)

final def map[B](f: A => B): Flash[B] = self.flatMap(a => Flash.succeed(f(a)))

final def orElse[B >: A](that: => Flash[B]): Flash[B] = Flash.OrElse(self, that)

/**
* Operator alias for `orElse`.
*/
final def <>[B >: A](that: => Flash[B]): Flash[B] = self.orElse(that)

final def zip[B](that: => Flash[B]): Flash[(A, B)] = self.zipWith(that)((a, b) => a -> b)

/**
* Operator alias for `zip`.
*/
final def <*>[B](that: => Flash[B]): Flash[(A, B)] = self.zip(that)

final def zipWith[B, C](that: => Flash[B])(f: (A, B) => C): Flash[C] =
self.flatMap(a => that.map(b => f(a, b)))

final def optional: Flash[Option[A]] = self.map(Option(_)) <> Flash.succeed(None)

final def foldHtml[A1 >: A, B](f: Html => B, g: Html => B)(h: (B, B) => B)(implicit
ev: A1 =:= Flash.Message[Html, Html],
): Flash[B] =
self.map(a => a.asInstanceOf[A1].fold(f, g)(h))

final def toHtml[A1 >: A](implicit ev: A1 =:= String): Flash[Html] =
self.map(Html.fromString(_))

}

object Flash {

/**
* A fash message can represent a notice, an alert or both - it's some kind of
* a specialized `zio.prelude.These`.
*
* Using a flash message allows one to categorize those into notice or alert
* and by that wrap both messages with a different ui design.
*/
sealed trait Message[+A, +B] { self =>

/**
* Folds a notice with `f` into `C`, an alert with `g` into `C` and both
* with `h` into another `C`.
*/
def fold[C](f: A => C, g: B => C)(h: (C, C) => C): C = this match {
case Message.Notice(a) => f(a)
case Message.Alert(b) => g(b)
case Message.Both(Message.Notice(a), Message.Alert(b)) => h(f(a), g(b))
}

/**
* Returns true if this `Message` represents both, a notice and and alert.
*/
def isBoth: Boolean = this match {
case Message.Both(_, _) => true
case _ => false
}

/**
* Returns true if this `Message` represents a notice only.
*/
def isNotice = this match {
case Message.Notice(_) => true
case _ => false
}

/**
* Returns true if this `Message` represents an alert only.
*/
def isAlert = this match {
case Message.Alert(_) => true
case _ => false
}
}
private[http] object Message {
case class Notice[+A](a: A) extends Message[A, Nothing]
private[http] object Notice {
val name = "notice"
}
case class Alert[+B](b: B) extends Message[Nothing, B]
private[http] object Alert {
val name = "alert"
}
private[http] case class Both[+A, +B](notice: Notice[A], alert: Alert[B]) extends Message[A, B]
}

/**
* `Flash.Backend` represents a flash-scope that is not cookie-based but
* instead uses an internal structure.
*
* Semantically it is identical to the cookie-based flash-scope (valid for a
* single request) but by using `Flash.Backend` we're not limited in size of
* the payload as in the cookie-based flash-scope. Still, the `Flash.Backend`
* uses a cookie but does not transport the payload with it but only an
* internal identifier.
*/
trait Backend { self =>

/**
* Gets an `A` from the backend-based flash-scope or fails with a
* `Throwable`.
*/
def flash[A](request: Request, flash: Flash[A]): IO[Throwable, A]

/**
* Gets an `A` from the backend-based flash-scope and provides a fallback.
*/
final def flashOrElse[A](request: Request, flash: Flash[A])(orElse: => A): UIO[A] =
self.flash(request, flash) <> ZIO.succeed(orElse)

/**
* Adds flash values to the backend-based flash-scope and returns a workflow
* with an updated `Response`.
*/
def addFlash[A](response: Response, setter: Flash.Setter[A]): UIO[Response]

/**
* Optionally adds flash values to the backend-based flash-scope and returns
* a workflow with an updated `Response`.
*/
final def addFlash[A](response: Response, setterOpt: Option[Flash.Setter[A]]): UIO[Response] =
setterOpt.fold(ZIO.succeed(response))(self.addFlash(response, _))
}

object Backend {

private case class Impl(ref: Ref[Map[UUID, Map[String, String]]]) extends Backend {
override final def flash[A](request: Request, flash: Flash[A]): IO[Throwable, A] =
for {
flashId <- ZIO.from(Flash.run(Flash.getUUID(flashIdName), request))
a <- ref.modify { map =>
Flash.run(flash, map.get(flashId).getOrElse(Map.empty)) match {
case value @ Right(_) => value -> (map - flashId)
case value @ Left(_) => value -> map
}
}.flatMap(ZIO.from(_))
} yield a

override final def addFlash[A](response: Response, setter: Setter[A]): UIO[Response] = {
val map = Flash.Setter.run(setter, Map.empty)
for {
flashId <- zio.Random.nextUUID
setterFlashId <- ref.update(in => in + (flashId -> map)).as(Flash.setValue(flashIdName, flashId))
} yield response.addFlash(setterFlashId)
}
}

/**
* Provides a `Flash.Backend` based on a `Ref` in-memory.
*/
val inMemory: ULayer[Backend] = ZLayer(Ref.make(Map.empty[UUID, Map[String, String]]).map(Impl.apply))

private val flashIdName = "flashId"

}

sealed trait Setter[A] { self =>

/**
* Combines setting this flash value with another setter `that`.
*/
final def ++[B](that: => Setter[B]): Setter[(A, B)] = Setter.Concat(self, that)
}

private[http] object Setter {

case object Empty extends Flash.Setter[Unit]

case class SetValue[A](schema: Schema[A], key: String, a: A) extends Flash.Setter[A]

case class Concat[A, B](left: Setter[A], right: Setter[B]) extends Flash.Setter[(A, B)]

def run[A](setter: Setter[A]): Cookie.Response =
Cookie.Response(
Flash.COOKIE_NAME,
URLEncoder.encode(
JsonCodec.jsonEncoder(Schema[Map[String, String]]).encodeJson(run(setter, Map.empty)).toString,
StandardCharsets.UTF_8.toString.toLowerCase,
),
)

def run[A](setter: Setter[A], map: Map[String, String]): Map[String, String] = {
def loop[B](setter: Setter[B], map: Map[String, String]): Map[String, String] =
setter match {
case SetValue(schema, key, a) =>
map.updated(key, JsonCodec.jsonEncoder(schema).encodeJson(a).toString)
case Concat(left, right) =>
loop(right, loop(left, map))
case Empty => map
}
loop(setter, map)
}
}

/**
* Sets a flash value of type `A` with the given key `key`.
*/
def setValue[A: Schema](key: String, a: A): Setter[A] = Setter.SetValue(Schema[A], key, a)

/**
* Sets a flash value of type `A` with the key for a notice.
*/
def setNotice[A: Schema](a: A): Setter[A] = Setter.SetValue(Schema[A], Message.Notice.name, a)

/**
* Sets a flash value of type `A` with the key for an alert.
*/
def setAlert[A: Schema](a: A): Setter[A] = Setter.SetValue(Schema[A], Message.Alert.name, a)

def setEmpty: Setter[Unit] = Setter.Empty

private[http] val COOKIE_NAME = "zio-http-flash"

private case class Get[A](schema: Schema[A], key: String) extends Flash[A]

private case class FlatMap[A, B](self: Flash[A], f: A => Flash[B]) extends Flash[B]

private case class OrElse[A, B >: A](self: Flash[A], that: Flash[B]) extends Flash[B]

private case class WithInput[A](f: Map[String, String] => Flash[A]) extends Flash[A]

private case class Succeed[A](a: A) extends Flash[A]

private case class Fail(message: String) extends Flash[Nothing]

def succeed[A](a: A): Flash[A] = Succeed(a)

def fail(message: String): Flash[Nothing] = Fail(message)

private def withInput[A](f: Map[String, String] => Flash[A]): Flash[A] = WithInput(f)

private def getMessage[A: Schema, B: Schema]: Flash[Message[A, B]] =
getMessage(Flash.get[A](Message.Notice.name), Flash.get[B](Message.Alert.name))

/**
* Creates a `Flash.Message` from two other values `flashNotice` and
* `flashAlert`.
*
* Uses `flashNotice` to create a `Flash.Message` representing a notice.
*
* Uses `flashAlert` to create a `Flash.Message` representing an alert.
*
* If `flashNotice` and `flashAlert` are both available in the flash scope the
* resulting `Flash.Message` will represent both.
*/
def getMessage[A, B](flashNotice: Flash[A], flashAlert: Flash[B]): Flash[Message[A, B]] =
(flashNotice.optional <*> flashAlert.optional).flatMap {
case (Some(a), Some(b)) => Flash.succeed(Flash.Message.Both(Flash.Message.Notice(a), Flash.Message.Alert(b)))
case (Some(a), _) => Flash.succeed(Flash.Message.Notice(a))
case (_, Some(b)) => Flash.succeed(Flash.Message.Alert(b))
case _ => Flash.fail(s"neither '${Message.Notice.name}' nor '${Message.Alert.name}' do exist in the flash-scope")
}

private def getMessageHtml[A: Schema, B: Schema](f: A => Html, g: B => Html): Flash[Message[Html, Html]] =
getMessage[A, B].map {
case Message.Notice(a) => Message.Notice(f(a))
case Message.Alert(b) => Message.Alert(g(b))
case Message.Both(Message.Notice(a), Message.Alert(b)) => Message.Both(Message.Notice(f(a)), Message.Alert(g(b)))
}

/**
* Creates a `Flash.Message` using the default keys for notice and alert.
*
* Additionally the values must be of type `String` so they can be transformed
* to `Html`.
*
* Usage e.g.: `Flash.getMessageHtml.foldHtml(showNotice, showAlert)(_ ++ _)`
*/
def getMessageHtml: Flash[Message[Html, Html]] =
getMessageHtml[String, String](a => Dom.text(a), b => Dom.text(b))

/**
* Gets any flash value of type `A` with the given key `key`.
*/
def get[A: Schema](key: String): Flash[A] = Flash.Get(Schema[A], key)

/**
* Gets a flash value of type `String` with the given key `key`.
*/
def getString(key: String): Flash[String] = get[String](key)

/**
* Gets a flash value of type `A` associated with the notice key.
*/
def getNotice[A: Schema]: Flash[A] = get[A](Message.Notice.name)

/**
* Gets a flash value of type `A` associated with the alert key.
*/
def getAlert[A: Schema]: Flash[A] = get[A](Message.Alert.name)

/**
* Gets a flash value of type `Float` with the given key `key`.
*/
def getFloat(key: String): Flash[Float] = get[Float](key)

/**
* Gets a flash value of type `Double` with the given key `key`.
*/
def getDouble(key: String): Flash[Double] = get[Double](key)

/**
* Gets a flash value of type `Int` with the given key `key`.
*/
def getInt(key: String): Flash[Int] = get[Int](key)

/**
* Gets a flash value of type `Long` with the given key `key`.
*/
def getLong(key: String): Flash[Long] = get[Long](key)

/**
* Gets a flash value of type `UUID` with the given key `key`.
*/
def getUUID(key: String): Flash[UUID] = get[UUID](key)

/**
* Gets a flash value of type `Boolean` with the given key `key`.
*/
def getBoolean(key: String): Flash[Boolean] = get[Boolean](key)

/**
* Gets the first flash value of type `A` regardless of any key.
*/
def get[A: Schema]: Flash[A] = withInput { map =>
map.keys.map(a => Flash.get(a)(Schema[A])).reduce(_ <> _)
}

private[http] def run[A](flash: Flash[A], sourceRequest: Request): Either[Throwable, A] =
sourceRequest
.cookie(COOKIE_NAME)
.toRight(new RuntimeException("flash cookie doesn't exist"))
.flatMap { cookie =>
try {
val content =
URLDecoder.decode(cookie.content, StandardCharsets.UTF_8.toString.toLowerCase)
JsonCodec.jsonDecoder(Schema.map[String, String]).decodeJson(content).left.map(e => new RuntimeException(e))
} catch {
case e: Exception => Left(e)
}
}
.flatMap(in => run(flash, in))

private[http] def run[A](flash: Flash[A], sourceMap: Map[String, String]): Either[Throwable, A] = {
def loop[A](flash: Flash[A], map: Map[String, String]): Either[Throwable, A] =
flash match {
case Get(schema, key) =>
map
.get(key)
.toRight(new RuntimeException(s"""flash key doesn't exist: "${key}" (existing flash keys: "${map.keys
.mkString(", ")}")"""))
.flatMap { value =>
JsonCodec.jsonDecoder(schema).decodeJson(value).left.map(e => new RuntimeException(e))
}
case WithInput(f) =>
loop(f(map), map)
case OrElse(self, that) =>
loop(self, map) match {
case Left(_) => loop(that, map)
case r @ Right(_) => r.asInstanceOf[Either[Throwable, A]]
}
case FlatMap(self, f) =>
loop(self, map) match {
case Right(value) => loop(f(value), map)
case l @ Left(_) => l.asInstanceOf[Either[Throwable, A]]
}
case Succeed(a) => Right(a)
case Fail(message) => Left(new RuntimeException(message))
}
loop(flash, sourceMap)
}
}
Loading

0 comments on commit f4b68d2

Please sign in to comment.