Skip to content

Latest commit

 

History

History
247 lines (184 loc) · 12.2 KB

README.md

File metadata and controls

247 lines (184 loc) · 12.2 KB

lambda-days-free-workshop

Purpose

The purpose of this project is to demonstrate how a REST app can be written in a completely pure, functional manner. It also serves as a playground for various ideas: tagged types, WithId, JWT, akka-http directives. Feel free to collaborate

Ideas

Architecture

Architecture is based on ideas from Functional and Reactive Domain Modelling. It retains 3-layered approach: there are services, resources and repositories. The meaning of key components differs from what you'd expect from non-Free program, though.

Service

Service constructs a Free program consisting of ADTs found in repositories. Thus, you can say, that repository formulates a language, which any services can use to build a complete functionality. Service is normally a trait which imports repositories for ADTs and domain objects. Services are completely pure and testable. For example, to store a post:

  def newPost(threadId: ThreadId, authorId: AuthorId, postContent: PostContent): PostOperation[Either[PostError, FullPostWithId]] =
    lookupThread(threadId)
      .toEither(ifNone = NoSuchThread)
      .subflatMap(thread => Either.cond(test = !thread.isClosed, thread, ThreadClosed))
      .flatMapF(thread => PostRepository.storePost(thread.id, authorId, postContent))
      .value

  def lookupThread(id: ThreadId)                    = PostRepository.lookupThread(id)

Obviously - secret of testability lies in services' independence of actual interpretation and ability to swap interpreters

More sophisticated examples include configurable operations eg.

  def passwordPolicy: PasswordPlain => Either[PasswordTooWeak.type, String]
  def confirmationTokenTtl: Duration

  def update(id: AccountId, accountChangeRequest: AccountChangeRequest): AccountOperation[StoreResult] =
    (for {
      _       <- EitherT.fromEither[AccountOperation](accountChangeRequest.validatePassword(passwordPolicy))
      updated <- EitherT(AccountRepository.update(id, accountChangeRequest.updateFunction))
    } yield updated).value

  def confirm(accountConfirmRequest: AccountConfirmRequest): ConfirmationTokenWithAccountOperation[Either[ConfirmationError, Ok.type]] =
    ConfirmationTokenOrAccount
      .queryToken(accountConfirmRequest.email, accountConfirmRequest.confirmationToken)
      .toEither(ifNone = InvalidToken)
      .subflatMap(confirmationToken =>
        Either.cond(test = !confirmationToken.isExpired(confirmationTokenTtl), accountConfirmRequest.email, TokenExpired))
      .flatMapF(email => ConfirmationTokenOrAccount.setConfirmed(email, accountConfirmRequest.nick))
      .value

Tests can fake anything

  val accountService = new AccountService {
    private val passwordMinLength = 4

    override val passwordPolicy = (password: PasswordPlain) =>
      for {
        _ <- mustBeOfLength(passwordMinLength)(password)
        _ <- mustContainLetter(password)
        _ <- mustContainUpper(password)
      } yield password

    override val confirmationTokenTtl = Duration.ofMinutes(1)
    override val messages = new Messages {
      override val from = EmailAddress("noreply@example.com".@@[Email], name = Some("no-reply"))
    }

  }

Also, you can test things that happen out-of-band like sending emails plus its complex interactions with generating tokens:

 type EmailLog[V] = Writer[List[EmailMessage], V]

  protected def writeEmailLogValue: Id ~> EmailLog = λ[Id ~> EmailLog](Writer.value(_))
  def mailingLogger: MailingRepository ~> EmailLog =
    λ[MailingRepository ~> EmailLog] {
      case SendEmail(message) => Writer(List(message), Ok)
    }
  val confirmationTokenOrEmailLoggingOrAccountInterpreter
    : ConfirmationTokenOrEmailOrAccount ~> EmailLog = (accountInterpreter andThen writeEmailLogValue) or (mailingLogger or (tokenInterpreter andThen writeEmailLogValue))
    
  it("should send confirmation email") {
     new AccountFixture with ConfirmationTokenFixture with MailingFixture {
        import cats.instances.list._

        val log = createF(AccountCreateRequest("user@example.com".@@[Email], "Dr56::sf".@@[Password]))(confirmationEmailEnv).value
          .foldMap(confirmationTokenOrEmailLoggingOrAccountInterpreter)

        val emails = log.written
        emails should have length 1
        
        val token = tokenInterpreter.find("user@example.com".@@[Email]).head

        emails.head should matchPattern {
          case accountService.messages.ConfirmationMessage(email, _, link)
              if email == "user@example.com" && link == confirmationEmailEnv.confirmationLink("user@example.com".@@[Email], token) =>
        }
     }
  }

Repository

Repository is not a db-repository where you'll typically find queries or tables but rather collection of ADTs for describing interaction with storage. For instance

sealed trait MailingRepository[A]

object MailingRepository {
  case class SendEmail(message: EmailMessage) extends MailingRepository[Ok.type]

  def sendEmail(message: EmailMessage) = Free.liftF(SendEmail(message))
}

These are used by services to create complex scenarios. Eventually, you'll write some specific interpreters for these eg. backed by a db. But these are implementation details. Also, please note that Slick stuff is called repository too - but it is in utils Repository.scala. As such, it is only used by a specific interpreter. So - in a nutshell what you treated as Slick repo is now broken down into two things: collection of generic queries (found in utils) plus model and specific queries (as interpreter).

Repositories are tested usually in it:test (because they involve some external system as SMTP server or a DB)

Resources

Resources are facing outside world via HTTP interface - sometimes you call them routers. They demand a service (for making a program) and an interpreter (for, well, interpreting it - usually to a Future) and push the result to the caller via akka-http. Typical interaction:

trait PostsResource extends Resource with PostsResource.PostsProtocol {
  def postService: PostService
  def postRepositoryInterpreter: PostRepositoryInterpreter

  import PostsResource._
  import cats.instances.future._

  private def runCreateNewThread(authorId: AuthorId, newThreadRequest: NewThreadRequest) =
    postService.newThread(authorId, newThreadRequest) foldMap postRepositoryInterpreter
  protected lazy val createNewThread: Route = (post & extractClaims) { claims =>
    val authorId = claims.id.asAuthorId

    entity(as[NewThreadRequest]) { newThreadRequest =>
      withBaseUri(implicit uri => onSuccess(runCreateNewThread(authorId, newThreadRequest))(completeWithLocation))
    }
  }

  private def runLookupThread(threadId: ThreadId) =
    postService.lookupThread(threadId) foldMap postRepositoryInterpreter
  protected val lookupThread: ThreadId => Route = id =>
    get {
      jwtAuthorize() {
        onSuccess(runLookupThread(id)) {
          case None => complete(NotFound)
          case Some(thread) =>
            withBaseUri(implicit uri => completeAsResource(thread))
        }
      }
  }

  val threadsRoutes: Route = pathEndOrSingleSlash { createNewThread ~ fetchThreads } ~
    pathPrefix(LongNumber.map(ThreadId(_))) { threadId =>
      pathEnd { lookupThread(threadId) } ~
        pathPrefix("posts") {
          pathEnd { createNewPost(threadId) ~ fetchPosts(threadId) } ~
            path(LongNumber.map(PostId(_))) { postId =>
              lookupPost(threadId)(postId) ~ updatePost(threadId)(postId)
            }
        }
    }
}

They are also totally unit-testable. Sometimes they need to provide some more complex environment (for example, to send an email), or combine multiple interpreters together

trait AccountsResource extends Resource with AccountsResource.AccountsProtocol with LanguageSupport {
  def accountService: AccountService
  def accountRepositoryInterpreter: AccountRepositoryInterpreter
  def confirmationTokenInterpreter: ConfirmationTokenRepositoryInterpreter
  def mailingInterpreter: MailingRepositoryInterpreter

  def confirmationLinkTemplate: String

  import AccountsResource.{AccountsResourceObject, MailEnv}
  import cats.instances.future._

  private def withMailEnv = extractHost.flatMap { host =>
    determineLocale().flatMap { locale =>
      provide(new MailEnv(locale, host, confirmationLinkTemplate))
    }
  }

  private def runCreateAccount(accountCreateRequest: AccountCreateRequest)(mailEnv: ConfirmationEmailEnv) =
    accountService
      .createRegular(accountCreateRequest)(mailEnv)
      .value foldMap (accountRepositoryInterpreter or (mailingInterpreter or confirmationTokenInterpreter))
  protected val createAccount: Route = (post & entity(as[AccountCreateRequest])) { accountCreateRequest =>
    withMailEnv { mailEnv =>
      onSuccess(runCreateAccount(accountCreateRequest)(mailEnv)) {
        case Left(error)          => complete(Conflict -> error)
        case Right(accountWithId) => completeWithLocation(accountWithId)
      }
    }
  }
}