com.mchange.feedletter.api.core.scala Maven / Gradle / Ivy
package com.mchange.feedletter.api
import com.mchange.feedletter.*
import com.mchange.feedletter.db.PgDatabase
import com.mchange.feedletter.style.StatusChangeInfo
import com.mchange.cryptoutil.{*,given}
import com.mchange.mailutil.Smtp
import com.mchange.conveniences.string.*
import com.mchange.conveniences.throwable.*
import com.mchange.conveniences.www.*
import sttp.model.QueryParams
import sttp.tapir.Schema
import zio.*
import java.time.Instant
import javax.sql.DataSource
import scala.io.Codec
import scala.collection.immutable
import com.mchange.feedletter.db.withConnectionTransactional
import com.mchange.feedletter.AppSetup
import com.mchange.feedletter.SubscriptionManager
import upickle.default.*
import sttp.tapir.json.upickle.*
trait ApiLinkGenerator:
def createGetLink( subscribableName : SubscribableName, destination : Destination ) : String
def confirmGetLink( sid : SubscriptionId ) : String
def removeGetLink( sid : SubscriptionId ) : String
object V0 extends SelfLogging:
import MLevel.*
given ReadWriter[RequestPayload.Subscription.Create] = ReadWriter.derived
given ReadWriter[RequestPayload.Subscription.Confirm] = ReadWriter.derived
given ReadWriter[RequestPayload.Subscription.Remove] = ReadWriter.derived
given ReadWriter[SubscriptionStatusChanged.Info] = ReadWriter.derived
given ReadWriter[SubscriptionStatusChanged] = ReadWriter.derived
given ReadWriter[ResponsePayload.Subscription.Created] = ReadWriter.derived
given ReadWriter[ResponsePayload.Subscription.Confirmed] = ReadWriter.derived
given ReadWriter[ResponsePayload.Subscription.Removed] = ReadWriter.derived
given ReadWriter[ResponsePayload.Failure] = ReadWriter.derived
// given Schema[Destination] = Schema.derived[Destination]
// given Schema[SubscriptionStatusChanged.Info] = Schema.derived[SubscriptionStatusChanged.Info]
// given Schema[SubscriptionStatusChanged] = Schema.derived[SubscriptionStatusChanged]
// given Schema[RequestPayload.Subscription.Create] = Schema.derived[RequestPayload.Subscription.Create]
// given Schema[RequestPayload.Subscription.Confirm] = Schema.derived[RequestPayload.Subscription.Confirm]
// given Schema[RequestPayload.Subscription.Remove] = Schema.derived[RequestPayload.Subscription.Remove]
// given Schema[ResponsePayload] = Schema.derived[ResponsePayload]
// given Schema[ResponsePayload.Subscription.Created] = Schema.derived[ResponsePayload.Subscription.Created]
// given Schema[ResponsePayload.Subscription.Confirmed] = Schema.derived[ResponsePayload.Subscription.Confirmed]
// given Schema[ResponsePayload.Subscription.Removed] = Schema.derived[ResponsePayload.Subscription.Removed]
// given Schema[ResponsePayload.Success] = Schema.derived[ResponsePayload.Success]
// given Schema[ResponsePayload.Failure] = Schema.derived[ResponsePayload.Failure]
private def bytesUtf8( s : String ) : Array[Byte] = s.getBytes(scala.io.Codec.UTF8.charSet)
object RequestPayload:
extension ( queryParams : QueryParams )
def assertParam( key : String ) : String =
queryParams.get(key).getOrElse:
throw new InvalidRequest( s"Expected query param '$key' required, not found." )
object Subscription:
object Create:
def fromQueryParams( queryParams : QueryParams ) : Create =
val subscribableName : String = queryParams.assertParam( "subscribableName" )
val destination : Destination = Destination.fromFields( queryParams.toSeq ).getOrElse:
throw new InvalidRequest( "Could not decode a Destination for subscription create request. Fields: " + queryParams.toSeq.mkString(", ") )
Create( subscribableName, destination )
case class Create( subscribableName : String, destination : Destination ) extends RequestPayload:
override lazy val toMap : Map[String,String] = Map( "subscribableName" -> subscribableName ) ++ destination.toFields
object Confirm extends Bouncer[Confirm]("ConfirmSuffix"):
def fromQueryParams( queryParams : QueryParams ) : Confirm =
val subscriptionId : Long = queryParams.assertParam( "subscriptionId" ).toLong
val invitation : String = queryParams.assertParam( "invitation" )
Confirm( subscriptionId, invitation )
def noninvitationContentsBytes( req : Confirm ) = req.subscriptionId.toByteSeqBigEndian
def invite( subscriptionId : Long, secretSalt : String ) : Confirm =
val nascent = Confirm( subscriptionId, "" )
nascent.copy( invitation = regenInvitation( nascent, secretSalt ) )
case class Confirm( subscriptionId : Long, invitation : String ) extends RequestPayload.Invited
object Remove extends Bouncer[Remove]("RemoveSuffix"):
def fromQueryParams( queryParams : QueryParams ) : Remove =
val subscriptionId : Long = queryParams.assertParam( "subscriptionId" ).toLong
val invitation : String = queryParams.assertParam( "invitation" )
Remove( subscriptionId, invitation )
def noninvitationContentsBytes( req : Remove ) = req.subscriptionId.toByteSeqBigEndian
def invite( subscriptionId : Long, secretSalt : String ) : Remove =
val nascent = Remove( subscriptionId, "" )
nascent.copy( invitation = regenInvitation( nascent, secretSalt ) )
case class Remove( subscriptionId : Long, invitation : String ) extends RequestPayload.Invited
sealed trait Invited extends RequestPayload:
def invitation : String
sealed trait Bouncer[T <: Invited]( suffix : String ):
def noninvitationContentsBytes( req : T ) : immutable.Seq[Byte]
def genInvitationBytes( req : T, secretSalt : String ) : immutable.Seq[Byte] =
Hash.SHA3_256.hash(noninvitationContentsBytes(req) ++ bytesUtf8(secretSalt) ++ bytesUtf8(suffix)).toSeq
def regenInvitation( req : T, secretSalt : String ) : String = genInvitationBytes(req,secretSalt).hex
def checkInvitation( req : T, secretSalt : String ) : Boolean =
val received = req.invitation.decodeHexToSeq
val expected = genInvitationBytes(req, secretSalt)
received == expected
def assertInvitation( req : T, secretSalt : String ) : Unit =
if !checkInvitation(req, secretSalt) then
throw new InvalidInvitation( s"'${req.invitation}' is not a valid invitation for request ${req}. Cannot process." )
end Bouncer
sealed trait RequestPayload extends Product:
lazy val toMap : Map[String,String] = (0 until productArity).map( i => (productElementName(i), productElement(i).toString) ).toMap
lazy val toGetParams : String = wwwFormEncodeUTF8( toMap.toSeq* )
object SubscriptionStatusChanged:
case class Info( subscribableName : String, destination : Destination ) // does NOT extend SubscriptionStatusChanged
case class Created( info : Info ) extends SubscriptionStatusChanged( SubscriptionStatusChange.Created )
case class Confirmed( info : Info ) extends SubscriptionStatusChanged( SubscriptionStatusChange.Confirmed )
case class Removed( info : Option[Info] ) extends SubscriptionStatusChanged( SubscriptionStatusChange.Removed )
sealed trait SubscriptionStatusChanged( val statusChange : SubscriptionStatusChange )
extension ( sinfo : SubscriptionInfo )
def thin : SubscriptionStatusChanged.Info = SubscriptionStatusChanged.Info( sinfo.name.str, sinfo.destination )
object ResponsePayload:
object Subscription:
case class Created( message : String, id : Long, confirmationRequired : Boolean, statusChanged : SubscriptionStatusChanged.Created, success : Boolean = true ) extends ResponsePayload.Success
case class Confirmed( message : String, id : Long, statusChanged : SubscriptionStatusChanged.Confirmed, success : Boolean = true ) extends ResponsePayload.Success
case class Removed( message : String, id : Long, statusChanged : SubscriptionStatusChanged.Removed, success : Boolean = true ) extends ResponsePayload.Success
sealed trait Success extends ResponsePayload:
def statusChanged : SubscriptionStatusChanged
case class Failure( message : String, throwableClassName : Option[String], fullStackTrace : Option[String], success : Boolean = false ) extends ResponsePayload
sealed trait ResponsePayload:
def success : Boolean
def message : String
class TapirApi(val serverUrl : String, val locationPathElements : List[String], val secretSalt : String) extends ApiLinkGenerator:
val basePathElements = locationPathElements ::: "v0" :: "subscription" :: Nil
val createPathElements = basePathElements ::: "create" :: Nil
val confirmPathElements = basePathElements ::: "confirm" :: Nil
val removePathElements = basePathElements ::: "remove" :: Nil
val createFullPath = createPathElements.mkString("/","/","")
val confirmFullPath = confirmPathElements.mkString("/","/","")
val removeFullPath = removePathElements.mkString("/","/","")
val createEndpointUrl = pathJoin( serverUrl, createFullPath )
val confirmEndpointUrl = pathJoin( serverUrl, confirmFullPath )
val removeEndpointUrl = pathJoin( serverUrl, removeFullPath )
def createGetLink( subscribableName : SubscribableName, destination : Destination ) : String =
val createRequest = RequestPayload.Subscription.Create( subscribableName.str, destination )
createEndpointUrl + "?" + createRequest.toGetParams
def confirmGetLink( sid : SubscriptionId ) : String =
val confirmRequest = RequestPayload.Subscription.Confirm.invite(sid.toLong, secretSalt)
confirmEndpointUrl + "?" + confirmRequest.toGetParams
def removeGetLink( sid : SubscriptionId ) : String =
val removeRequest = RequestPayload.Subscription.Remove.invite(sid.toLong, secretSalt)
removeEndpointUrl + "?" + removeRequest.toGetParams
object BasicEndpoint:
import sttp.tapir.ztapir.*
import sttp.tapir.json.upickle.*
import sttp.tapir.PublicEndpoint
def addElements[IN,ERROUT,OUT,R]( baseEndpoint : PublicEndpoint[IN,ERROUT,OUT,R], elems : List[String] ) =
elems.foldLeft( baseEndpoint )( (accum,next) => accum.in(next) )
object Post:
import com.mchange.feedletter.api.V0.given
/*
Too much trouble deriving Tapir Schemas... we'll handle the JSON in our own logic
val Base = endpoint
.post
.errorOut( jsonBody[ResponsePayload.Failure] )
val Create = addElements(Base,createPathElements).in( jsonBody[RequestPayload.Subscription.Create] ).out( jsonBody[ResponsePayload.Subscription.Created] )
val Confirm = addElements(Base,confirmPathElements).in( jsonBody[RequestPayload.Subscription.Confirm] ).out( jsonBody[ResponsePayload.Subscription.Confirmed] )
val Remove = addElements(Base,removePathElements).in( jsonBody[RequestPayload.Subscription.Remove] ).out( jsonBody[ResponsePayload.Subscription.Removed] )
*/
val Base = endpoint
.post
.in( stringJsonBody )
.out( stringJsonBody )
.errorOut( stringJsonBody )
val Create = addElements(Base,createPathElements)
val Confirm = addElements(Base,confirmPathElements)
val Remove = addElements(Base,removePathElements)
object Get:
val Base = endpoint
.get
.errorOut( stringBody )
.in( queryParams )
.out( htmlBodyUtf8 )
val Create = addElements(Base,createPathElements)
val Confirm = addElements(Base,confirmPathElements)
val Remove = addElements(Base,removePathElements)
end BasicEndpoint
object ServerEndpoint:
import BasicEndpoint.*
import sttp.tapir.ztapir.*
def allEndpoints( ds : DataSource, as : AppSetup ) : List[ZServerEndpoint[Any,Any]] =
List(
Post.Create.zServerLogic( subscriptionCreateLogicPost( ds, as ) ),
Post.Confirm.zServerLogic( subscriptionConfirmLogicPost( ds, as ) ),
Post.Remove.zServerLogic( subscriptionRemoveLogicPost( ds, as ) ),
Get.Create.zServerLogic( subscriptionCreateLogicGet( ds, as ) ),
Get.Confirm.zServerLogic( subscriptionConfirmLogicGet( ds, as ) ),
Get.Remove.zServerLogic( subscriptionRemoveLogicGet( ds, as ) ),
)
type ZSharedOut[T <: ResponsePayload.Success] = ZIO[Any,ResponsePayload.Failure,(Option[SubscriptionInfo],T)]
type ZPostOut = ZIO[Any,String,String]
type ZGetOut = ZIO[Any,String,String]
// type ZPostOut[T <: ResponsePayload.Success] = ZIO[Any,ResponsePayload.Failure,T]
def mapError[T]( task : Task[T] ) : ZIO[Any,ResponsePayload.Failure,T] =
task.mapError: t =>
WARNING.log("An error occurred while processing an API request.", t)
ResponsePayload.Failure(
message = t.getMessage(),
throwableClassName = Some( t.getClass.getName ),
fullStackTrace = Some( t.fullStackTrace )
)
val AlreadySubscribedClassName = classOf[AlreadySubscribed].getName
def sharedToGet[T <: ResponsePayload.Success]( zpo : ZSharedOut[T] ) : ZGetOut =
def failureToPlainText( f : ResponsePayload.Failure ) =
f.throwableClassName match
case Some( AlreadySubscribedClassName ) =>
s"""|${HoorayFiglet}
|
| You have already successfully subscribed to this mailing list.
|
| Details:
| ${f.message}
|""".stripMargin
case _ =>
val base =
s"""|The following failure has occurred:
|
| ${f.message}
|""".stripMargin
def throwablePart(fst : String) : String =
s"""|
|It was associated with the following exception:
|
|$fst
|""".stripMargin
f.fullStackTrace.fold(base)(fst => base + throwablePart(fst))
def successToHtmlText( tup : (Option[SubscriptionInfo],ResponsePayload.Success) ) : String =
val (mbSinfo, rp) = tup
mbSinfo match
case Some( sinfo ) =>
val sman = sinfo.manager
sman match
case vsman : SubscriptionManager.SupportsExternalSubscriptionApi =>
vsman.htmlForStatusChange( new StatusChangeInfo( rp.statusChanged.statusChange, sinfo.name, sman, sinfo.destination, !sinfo.confirmed, removeGetLink(sinfo.id), createGetLink(sinfo.name, sinfo.destination) ) )
case _ => // we should never see this, shared logic should have checked already
throw new InvalidSubscribable(s"Subscribable '${sinfo.name}' does not support the external subscription API. (Manager '${sinfo.manager}' does not support.)")
case None =>
"""|
| Subscription Re-removed
|
| Subscription Re-removed
| The subscription you are trying to remove has already been unsubscribed. Have a nice day!
|
|""".stripMargin
zpo.mapError( failureToPlainText ).map( successToHtmlText )
def subscriptionCreateLogicShared( ds : DataSource, as : AppSetup )( screate : RequestPayload.Subscription.Create ) : ZSharedOut[ResponsePayload.Subscription.Created] =
TRACE.log( s"subscriptionCreateLogicShared( $screate )" )
val mainTask =
withConnectionTransactional( ds ): conn =>
val sname = SubscribableName(screate.subscribableName)
val destination = screate.destination
if PgDatabase.destinationAlreadySubscribed( conn, destination, sname ) then
throw new AlreadySubscribed( s"${destination.shortDesc} is already subscribed to '$sname'." )
val (sman, sid) = PgDatabase.addSubscription( conn, true, sname, destination, false, Instant.now ) // validates the destination!
sman match
case vsman : SubscriptionManager.SupportsExternalSubscriptionApi =>
val cgl = confirmGetLink( sid )
val rgl = removeGetLink( sid )
val confirming = vsman.maybePromptConfirmation( conn, as, sid, sname, vsman.narrowDestinationOrThrow(destination), cgl, rgl )
val confirmedMessage =
if confirming then ", but unconfirmed. Please respond to the confirmation request, coming soon." else ". No confirmation necessary."
val sinfo = SubscriptionInfo( sid, sname, vsman, destination, !confirming )
( Some(sinfo), ResponsePayload.Subscription.Created(s"Subscription ${sid} successfully created${confirmedMessage}", sid.toLong, confirming, SubscriptionStatusChanged.Created(sinfo.thin)) )
case _ =>
throw new InvalidSubscribable(s"Can't subscribe. Subscribable '${sname}' does not support the external subscription API. (Manager '$sman' does not support.)")
mapError( mainTask )
def subscriptionCreateLogicPost( ds : DataSource, as : AppSetup )( screateJson : String ) : ZPostOut =
val screate = read[RequestPayload.Subscription.Create]( screateJson )
subscriptionCreateLogicShared( ds, as )( screate ).map( tup => write(tup(1)) ).mapError( failure => write(failure) )
def subscriptionCreateLogicGet( ds : DataSource, as : AppSetup )( qps : QueryParams ) : ZGetOut =
try
val screate = RequestPayload.Subscription.Create.fromQueryParams(qps)
val sharedOut = subscriptionCreateLogicShared( ds, as )( screate )
sharedToGet( sharedOut )
catch
case t : Throwable =>
ZIO.fail( t.fullStackTrace )
def subscriptionConfirmLogicShared( ds : DataSource, as : AppSetup )( sconfirm : RequestPayload.Subscription.Confirm ) : ZSharedOut[ResponsePayload.Subscription.Confirmed] =
val mainTask =
withConnectionTransactional( ds ): conn =>
val sid = SubscriptionId(sconfirm.subscriptionId)
RequestPayload.Subscription.Confirm.assertInvitation( sconfirm, as.secretSalt )
val mbSinfo = PgDatabase.subscriptionInfoForSubscriptionId( conn, sid )
val sinfo = mbSinfo.getOrElse:
throw new AssertionError( s"If a subscription successfully confirmed, it ought to be available to select from the database!")
sinfo.manager match
case vsman : SubscriptionManager.SupportsExternalSubscriptionApi =>
PgDatabase.updateConfirmed( conn, sid, true )
( Some(sinfo), ResponsePayload.Subscription.Confirmed(s"Subscription ${sid} of '${sinfo.destination.unique}' successfully confirmed.", sid.toLong, SubscriptionStatusChanged.Confirmed(sinfo.thin) ) )
case _ =>
throw new InvalidSubscribable(s"Can't comfirm. Subscribable '${sinfo.name}' does not support the external subscription API. (Manager '${sinfo.manager}' does not support.)")
mapError( mainTask )
def subscriptionConfirmLogicPost( ds : DataSource, as : AppSetup )( sconfirmJson : String ) : ZPostOut =
val sconfirm = read[RequestPayload.Subscription.Confirm]( sconfirmJson )
subscriptionConfirmLogicShared( ds, as )( sconfirm ).map( tup => write(tup(1)) ).mapError( failure => write(failure) )
def subscriptionConfirmLogicGet( ds : DataSource, as : AppSetup )( qps : QueryParams ) : ZGetOut =
try
val sconfirm = RequestPayload.Subscription.Confirm.fromQueryParams(qps)
val sharedOut = subscriptionConfirmLogicShared( ds, as )( sconfirm )
sharedToGet( sharedOut )
catch
case t : Throwable =>
ZIO.fail( t.fullStackTrace )
def subscriptionRemoveLogicShared( ds : DataSource, as : AppSetup )( sremove : RequestPayload.Subscription.Remove ) : ZSharedOut[ResponsePayload.Subscription.Removed] =
TRACE.log( s"subscriptionRemoveLogicShared( $sremove )" )
val mainTask =
withConnectionTransactional( ds ): conn =>
val sid = SubscriptionId(sremove.subscriptionId)
val mbSinfo = PgDatabase.unsubscribe( conn, sid )
val message =
mbSinfo match
case Some(sinfo) =>
sinfo.manager match
case vsman : SubscriptionManager.SupportsExternalSubscriptionApi =>
val d = vsman.narrowDestinationOrThrow(sinfo.destination)
vsman.maybeSendRemovalNotification(conn,as,sid,sinfo.name,d,createGetLink(sinfo.name,d))
s"Unsubscribed. Subscription ${sid} of '${sinfo.destination.unique}' successfully removed."
case _ =>
// aborts the transaction, rolls back the unsubscribe
throw new InvalidSubscribable(s"Can't remove. Subscribable '${sinfo.name}' does not support the external subscription API. (Manager '${sinfo.manager}' does not support.)")
case None =>
s"Subscription with ID ${sid} does not exist or has already been removed."
( mbSinfo, ResponsePayload.Subscription.Removed(message, sid.toLong,SubscriptionStatusChanged.Removed(mbSinfo.map(_.thin))) )
mapError( mainTask )
def subscriptionRemoveLogicPost( ds : DataSource, as : AppSetup )( sremoveJson : String ) : ZPostOut =
val sremove = read[RequestPayload.Subscription.Remove]( sremoveJson )
subscriptionRemoveLogicShared( ds, as )( sremove ).map( tup => write(tup(1)) ).mapError( failure => write(failure) )
def subscriptionRemoveLogicGet( ds : DataSource, as : AppSetup )( qps : QueryParams ) : ZGetOut =
try
val sremove = RequestPayload.Subscription.Remove.fromQueryParams(qps)
val sharedOut = subscriptionRemoveLogicShared( ds, as )( sremove )
sharedToGet( sharedOut )
catch
case t : Throwable =>
ZIO.fail( t.fullStackTrace )
// generated by https://www.askapache.com/online-tools/figlet-ascii/
val HoorayFiglet =
"""|hhhhhhh
|h:::::h
|h:::::h
|h:::::h
|h::::h hhhhh ooooooooooo ooooooooooo rrrrr rrrrrrrrr aaaaaaaaaaaaayyyyyyy yyyyyyy
|h::::hh:::::hhh oo:::::::::::oo oo:::::::::::oo r::::rrr:::::::::r a::::::::::::ay:::::y y:::::y
|h::::::::::::::hh o:::::::::::::::oo:::::::::::::::or:::::::::::::::::r aaaaaaaaa:::::ay:::::y y:::::y
|h:::::::hhh::::::h o:::::ooooo:::::oo:::::ooooo:::::orr::::::rrrrr::::::r a::::a y:::::y y:::::y
|h::::::h h::::::ho::::o o::::oo::::o o::::o r:::::r r:::::r aaaaaaa:::::a y:::::y y:::::y
|h:::::h h:::::ho::::o o::::oo::::o o::::o r:::::r rrrrrrraa::::::::::::a y:::::y y:::::y
|h:::::h h:::::ho::::o o::::oo::::o o::::o r:::::r a::::aaaa::::::a y:::::y:::::y
|h:::::h h:::::ho::::o o::::oo::::o o::::o r:::::r a::::a a:::::a y:::::::::y
|h:::::h h:::::ho:::::ooooo:::::oo:::::ooooo:::::o r:::::r a::::a a:::::a y:::::::y
|h:::::h h:::::ho:::::::::::::::oo:::::::::::::::o r:::::r a:::::aaaa::::::a y:::::y
|h:::::h h:::::h oo:::::::::::oo oo:::::::::::oo r:::::r a::::::::::aa:::a y:::::y
|hhhhhhh hhhhhhh ooooooooooo ooooooooooo rrrrrrr aaaaaaaaaa aaaa y:::::y
| y:::::y
| y:::::y
| y:::::y
| y:::::y
| yyyyyyy """.stripMargin
end TapirApi
© 2015 - 2025 Weber Informatics LLC | Privacy Policy