diff --git a/src/main/scala/io/github/tassiLuca/boundaries/either.scala b/src/main/scala/io/github/tassiLuca/boundaries/either.scala index bf1f8a40..5a5ab44a 100644 --- a/src/main/scala/io/github/tassiLuca/boundaries/either.scala +++ b/src/main/scala/io/github/tassiLuca/boundaries/either.scala @@ -13,9 +13,10 @@ object either: case Right(value) => value case Left(value) => break(Left(value)) - type ThrowableConverter[L] = Throwable => L - extension [R](t: Try[R]) - inline def ?[L](using Label[Left[L, Nothing]])(using converter: ThrowableConverter[L]): R = t match + inline def ?[L](using Label[Left[L, Nothing]])(using converter: Conversion[Throwable, L]): R = t match case Success(value) => value case Failure(exception) => break(Left(converter(exception))) + +object EitherConversions: + given Conversion[Throwable, String] = _.getMessage diff --git a/src/main/scala/io/github/tassiLuca/posts/PostsModel.scala b/src/main/scala/io/github/tassiLuca/posts/PostsModel.scala index a3164b63..4ebc35be 100644 --- a/src/main/scala/io/github/tassiLuca/posts/PostsModel.scala +++ b/src/main/scala/io/github/tassiLuca/posts/PostsModel.scala @@ -3,7 +3,6 @@ package io.github.tassiLuca.posts import gears.async.Async import java.util.Date -import scala.util.Try /** The model of a simple blog posts service. */ trait PostsModel: @@ -29,7 +28,6 @@ trait PostsModel: /** A function that verifies the content of the post, returning [[Right]] with the content of the post if the * verification passes or [[Left]] with the reason why failed. */ - type ContentVerifier = Async ?=> (Title, Body) => Either[String, PostContent] + type ContentVerifier = (Title, Body) => Either[String, PostContent] - trait AuthorsService: - def by(id: AuthorId)(using Async): Try[Author] + type AuthorsVerifier = (AuthorId) => Author diff --git a/src/main/scala/io/github/tassiLuca/posts/Utils.scala b/src/main/scala/io/github/tassiLuca/posts/Utils.scala index 0ed60a12..42fedff1 100644 --- a/src/main/scala/io/github/tassiLuca/posts/Utils.scala +++ b/src/main/scala/io/github/tassiLuca/posts/Utils.scala @@ -8,12 +8,12 @@ import java.time.LocalTime import scala.util.{Failure, Random, Success} extension (component: String) - def simulates(action: String)(using Async): Unit = + def simulates(action: String, minDuration: Int = 0, maxDuration: Int = 5_000)(using Async): Unit = println(s"[$component - ${Thread.currentThread()} @ ${LocalTime.now()}] $action") - AsyncOperations.sleep(Random.nextInt(10_000)) + AsyncOperations.sleep(Random.nextInt(maxDuration) + minDuration) println(s"[$component - ${Thread.currentThread()} @ ${LocalTime.now()}] ended $action") - def simulatesBlocking(action: String): Unit = + def simulatesBlocking(action: String, minDuration: Int = 0, maxDuration: Int = 5_000): Unit = println(s"[$component - ${Thread.currentThread()} @ ${LocalTime.now()}] $action") - Thread.sleep(Random.nextInt(10_000)) + Thread.sleep(Random.nextInt(maxDuration) + minDuration) println(s"[$component - ${Thread.currentThread()} @ ${LocalTime.now()}] ended $action") diff --git a/src/main/scala/io/github/tassiLuca/posts/direct/BlogPostsApp.scala b/src/main/scala/io/github/tassiLuca/posts/direct/BlogPostsApp.scala index c03e2ba1..0cbad1dc 100644 --- a/src/main/scala/io/github/tassiLuca/posts/direct/BlogPostsApp.scala +++ b/src/main/scala/io/github/tassiLuca/posts/direct/BlogPostsApp.scala @@ -8,6 +8,6 @@ trait BlogPostsApp extends PostsServiceComponent with PostsModel with PostsRepos override type Title = String val contentVerifier: ContentVerifier - val authorsService: AuthorsService + val authorsVerifier: AuthorsVerifier override val repository: PostsRepository = PostsRepository() diff --git a/src/main/scala/io/github/tassiLuca/posts/direct/PostsRepositoryComponent.scala b/src/main/scala/io/github/tassiLuca/posts/direct/PostsRepositoryComponent.scala index 60c6e843..3d6e0c11 100644 --- a/src/main/scala/io/github/tassiLuca/posts/direct/PostsRepositoryComponent.scala +++ b/src/main/scala/io/github/tassiLuca/posts/direct/PostsRepositoryComponent.scala @@ -3,8 +3,6 @@ package io.github.tassiLuca.posts.direct import gears.async.Async import io.github.tassiLuca.posts.{PostsModel, simulates} -import scala.util.Try - /** The component exposing blog posts repositories. */ trait PostsRepositoryComponent: context: PostsModel => @@ -15,13 +13,16 @@ trait PostsRepositoryComponent: /** The repository in charge of storing and retrieving blog posts. */ trait PostsRepository: /** Save the given [[post]]. */ - def save(post: Post)(using Async): Try[Post] + def save(post: Post)(using Async): Post + + /** Return true if a post exists with the given title, false otherwise. */ + def exists(postTitle: Title)(using Async): Boolean /** Load the post with the given [[postTitle]]. */ - def load(postTitle: Title)(using Async): Try[Post] + def load(postTitle: Title)(using Async): Option[Post] /** Load all the saved post. */ - def loadAll()(using Async): Try[LazyList[Post]] + def loadAll()(using Async): LazyList[Post] object PostsRepository: /** Constructs a new [[PostsRepository]]. */ @@ -30,16 +31,19 @@ trait PostsRepositoryComponent: private class PostsLocalRepository extends PostsRepository: private var posts: Set[Post] = Set() - override def save(post: Post)(using Async): Try[Post] = Try: - require(posts.count(_.title == post.title) == 0, "A post with same title has already been saved") + override def save(post: Post)(using Async): Post = + require(!exists(post.title), "A post with same title has already been saved") "PostsRepository" simulates s"saving post '${post.title}'" synchronized { posts = posts + post } post - override def load(postTitle: Title)(using Async): Try[Post] = Try: + override def exists(postTitle: Title)(using Async): Boolean = + posts.exists(_.title == postTitle) + + override def load(postTitle: Title)(using Async): Option[Post] = "PostsRepository" simulates s"loading post '$postTitle'" - posts.find(_.title == postTitle).get + posts.find(_.title == postTitle) - override def loadAll()(using Async): Try[LazyList[Post]] = Try: + override def loadAll()(using Async): LazyList[Post] = "PostsRepository" simulates s"loading all blog posts" LazyList.from(posts) diff --git a/src/main/scala/io/github/tassiLuca/posts/direct/PostsServiceComponent.scala b/src/main/scala/io/github/tassiLuca/posts/direct/PostsServiceComponent.scala index b34d8752..405fb063 100644 --- a/src/main/scala/io/github/tassiLuca/posts/direct/PostsServiceComponent.scala +++ b/src/main/scala/io/github/tassiLuca/posts/direct/PostsServiceComponent.scala @@ -3,11 +3,11 @@ package io.github.tassiLuca.posts.direct import gears.async.default.given import gears.async.{Async, Task} import io.github.tassiLuca.boundaries.either -import io.github.tassiLuca.boundaries.either.{?, ThrowableConverter} +import io.github.tassiLuca.boundaries.either.? +import io.github.tassiLuca.boundaries.EitherConversions.given import io.github.tassiLuca.posts.{PostsModel, simulates} import java.util.Date -import scala.util.{Failure, Success, Try} /** The blog posts service component. */ trait PostsServiceComponent: @@ -30,30 +30,36 @@ trait PostsServiceComponent: def all()(using Async): Either[String, LazyList[Post]] object PostsService: - def apply(contentVerifier: ContentVerifier, authorsService: AuthorsService): PostsService = + def apply(contentVerifier: ContentVerifier, authorsService: AuthorsVerifier): PostsService = PostsServiceImpl(contentVerifier, authorsService) private class PostsServiceImpl( contentVerifier: ContentVerifier, - authorsService: AuthorsService, + authorsVerifier: AuthorsVerifier, ) extends PostsService: - given ThrowableConverter[String] = (t: Throwable) => t.getMessage // TODO put in an object of given instances - - override def create(authorId: AuthorId, title: Title, body: Body)(using Async): Either[String, Post] = either: - val (author, content) = authorBy(authorId).run.zip(verifyContent(title, body).run).awaitResult.? - val post = Post(author, content.?._1, content.?._2, Date()) - context.repository.save(post).? - post + override def create(authorId: AuthorId, title: Title, body: Body)(using Async): Either[String, Post] = + if context.repository.exists(title) + then Left(s"A post entitled $title already exists") + else + either: + val content = verifyContent(title, body).run + val author = authorBy(authorId).run + val post = Post(author.awaitResult.?, content.await.?._1, content.await.?._2, Date()) + context.repository.save(post) + /* Pretending to make a call to the Authorship Service that keeps track of authorized authors. */ private def authorBy(id: AuthorId): Task[Author] = Task: - authorsService.by(id).get + "PostsService".simulates(s"getting author $id info...", maxDuration = 1_000) + authorsVerifier(id) + /* Some local computation that verifies the content of the post is appropriate (e.g. not offensive, ...). */ private def verifyContent(title: Title, body: Body): Task[Either[String, PostContent]] = Task: + "PostsService".simulates(s"verifying content of post '$title'", minDuration = 1_000) contentVerifier(title, body) - override def get(title: Title)(using Async): Either[String, Post] = either: - context.repository.load(title).? + override def get(title: Title)(using Async): Either[String, Post] = + context.repository.load(title).toRight(s"Post $title not found") override def all()(using Async): Either[String, LazyList[Post]] = either: - context.repository.loadAll().? + context.repository.loadAll() diff --git a/src/main/scala/io/github/tassiLuca/posts/quo/BlogPostsApp.scala b/src/main/scala/io/github/tassiLuca/posts/quo/BlogPostsApp.scala index 7ea243bb..cdab2b76 100644 --- a/src/main/scala/io/github/tassiLuca/posts/quo/BlogPostsApp.scala +++ b/src/main/scala/io/github/tassiLuca/posts/quo/BlogPostsApp.scala @@ -5,17 +5,24 @@ import io.github.tassiLuca.posts.PostsModel import scala.concurrent.duration.Duration import scala.concurrent.{Await, ExecutionContext} -object BlogPostsApp$ extends PostsServiceComponent with PostsModel with PostsRepositoryComponent: +trait BlogPostsApp extends PostsServiceComponent with PostsModel with PostsRepositoryComponent: override type AuthorId = String override type Body = String override type Title = String + val contentVerifier: ContentVerifier + val authorsVerifier: AuthorsVerifier + override val repository: PostsRepository = PostsRepository() - override val service: PostsService = PostsService() @main def usePostsApp(): Unit = given ExecutionContext = ExecutionContext.global - val app = BlogPostsApp$ + val app = new BlogPostsApp: + override val contentVerifier: ContentVerifier = (t, b) => Right((t, b)) + override val authorsVerifier: AuthorsVerifier = a => + require(a == "ltassi@gmail.com", "No author with the given id matches") + Author(a, "Luca", "Tassinari") + override val service: PostsService = PostsService(contentVerifier, authorsVerifier) val post = for _ <- app.service.create("ltassi@gmail.com", "A hello world post", "Hello World!") @@ -23,4 +30,3 @@ object BlogPostsApp$ extends PostsServiceComponent with PostsModel with PostsRep yield p Await.ready(post, Duration.Inf) println(post.value) - \ No newline at end of file diff --git a/src/main/scala/io/github/tassiLuca/posts/quo/PostsRepositoryComponent.scala b/src/main/scala/io/github/tassiLuca/posts/quo/PostsRepositoryComponent.scala index 784b37d9..635b485a 100644 --- a/src/main/scala/io/github/tassiLuca/posts/quo/PostsRepositoryComponent.scala +++ b/src/main/scala/io/github/tassiLuca/posts/quo/PostsRepositoryComponent.scala @@ -16,13 +16,16 @@ trait PostsRepositoryComponent: /** The repository in charge of storing and retrieving blog posts. */ trait PostsRepository: /** Save the given [[post]]. */ - def save(post: Post)(using ExecutionContext): Future[Unit] + def save(post: Post)(using ExecutionContext): Future[Post] - /** Load the post with the given [[postTitle]]. */ - def loadAll()(using ExecutionContext): Future[LazyList[Post]] + /** Return a future completed with true if a post exists with the given title, false otherwise. */ + def exists(postTitle: Title)(using ExecutionContext): Future[Boolean] /** Load all the saved post. */ - def load(postTitle: Title)(using ExecutionContext): Future[Post] + def load(postTitle: Title)(using ExecutionContext): Future[Option[Post]] + + /** Load the post with the given [[postTitle]]. */ + def loadAll()(using ExecutionContext): Future[LazyList[Post]] object PostsRepository: /** Constructs a new [[PostsRepository]]. */ @@ -31,14 +34,18 @@ trait PostsRepositoryComponent: private class PostsLocalRepository extends PostsRepository: private var posts: Set[Post] = Set() - override def save(post: Post)(using ExecutionContext): Future[Unit] = Future: - require(posts.count(_.title == post.title) == 0, "A post with same title has already been saved") - "PostsRepository" simulatesBlocking s"saving post ${post.title}" + override def save(post: Post)(using ExecutionContext): Future[Post] = Future: + require(!posts.exists(_.title == post.title), "A post with same title has already been saved") + "PostsRepository" simulatesBlocking s"saving post '${post.title}'" synchronized { posts = posts + post } + post + + override def exists(postTitle: Title)(using ExecutionContext): Future[Boolean] = Future: + posts.exists(_.title == postTitle) - override def load(postTitle: Title)(using ExecutionContext): Future[Post] = Future: - "PostsRepository" simulatesBlocking s"loading post $postTitle" - posts.find(_.title == postTitle).get + override def load(postTitle: Title)(using ExecutionContext): Future[Option[Post]] = Future: + "PostsRepository" simulatesBlocking s"loading post '$postTitle'" + posts.find(_.title == postTitle) override def loadAll()(using ExecutionContext): Future[LazyList[Post]] = Future: "PostsRepository" simulatesBlocking s"loading all blog posts" diff --git a/src/main/scala/io/github/tassiLuca/posts/quo/PostsServiceComponent.scala b/src/main/scala/io/github/tassiLuca/posts/quo/PostsServiceComponent.scala index 76aff4e4..113f9cf4 100644 --- a/src/main/scala/io/github/tassiLuca/posts/quo/PostsServiceComponent.scala +++ b/src/main/scala/io/github/tassiLuca/posts/quo/PostsServiceComponent.scala @@ -25,31 +25,37 @@ trait PostsServiceComponent: def all(): Future[LazyList[Post]] object PostsService: - def apply(): PostsService = PostsServiceImpl() + def apply(contentVerifier: ContentVerifier, authorsVerifier: AuthorsVerifier): PostsService = + PostsServiceImpl(contentVerifier, authorsVerifier) - private class PostsServiceImpl extends PostsService: + private class PostsServiceImpl( + contentVerifier: ContentVerifier, + authorsVerifier: AuthorsVerifier, + ) extends PostsService: - opaque type PostContent = (Title, Body) given ExecutionContext = ExecutionContext.global override def create(authorId: AuthorId, title: Title, body: Body): Future[Post] = - val author = authorBy(authorId) - val content = verifyContent(title, body) + val authorAsync = authorBy(authorId) + val contentAsync = verifyContent(title, body) for - a <- author - c <- content - post = Post(a, c._1, c._2, Date()) + content <- contentAsync + author <- authorAsync + post = Post(author, content._1, content._2, Date()) _ <- context.repository.save(post) yield post + /* Pretending to make a call to the Authorship Service that keeps track of authorized authors. */ private def authorBy(id: AuthorId)(using ExecutionContext): Future[Author] = Future: - "PostsService" simulatesBlocking s"getting author $id info..." - Author(id, "Luca", "Tassinari") + "PostsService".simulatesBlocking(s"getting author $id info...", maxDuration = 1_000) + authorsVerifier(id) - private def verifyContent(title: Title, body: Body)(using ExecutionContext): Future[PostContent] = Future: - "PostsService" simulatesBlocking s"verifying content of the post '$title'" - (title, body) + /* Some local computation that verifies the content of the post is appropriate (e.g. not offensive, ...). */ + private def verifyContent(title: Title, body: Body)(using ExecutionContext): Future[PostContent] = + Future: + "PostsService".simulatesBlocking(s"verifying content of the post '$title'", minDuration = 1_000) + contentVerifier(title, body) match { case Left(e) => throw RuntimeException(e); case Right(v) => v } - override def get(title: Title): Future[Post] = context.repository.load(title) + override def get(title: Title): Future[Post] = context.repository.load(title).map(_.get) override def all(): Future[LazyList[Post]] = context.repository.loadAll() diff --git a/src/test/scala/io/github/tassiLuca/posts/direct/BlogPostsServiceTest.scala b/src/test/scala/io/github/tassiLuca/posts/direct/BlogPostsServiceTest.scala index 5907a943..f639130c 100644 --- a/src/test/scala/io/github/tassiLuca/posts/direct/BlogPostsServiceTest.scala +++ b/src/test/scala/io/github/tassiLuca/posts/direct/BlogPostsServiceTest.scala @@ -2,13 +2,10 @@ package io.github.tassiLuca.posts.direct import gears.async.default.given import gears.async.{Async, Future} -import io.github.tassiLuca.posts.simulates import org.scalatest.BeforeAndAfterEach import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers.shouldBe -import scala.util.Try - class BlogPostsServiceTest extends AnyFlatSpec with BeforeAndAfterEach: private var blogPostsApp: BlogPostsApp = null @@ -18,25 +15,18 @@ class BlogPostsServiceTest extends AnyFlatSpec with BeforeAndAfterEach: override def beforeEach(): Unit = blogPostsApp = new BlogPostsApp: - override val contentVerifier: ContentVerifier = (t, b) => - "PostsService" simulates s"verifying content of post '$t'" - Right((t, b)) - override val authorsService: AuthorsService = new AuthorsService: - private val authors = Set( - Author("ltassi", "Luca", "Tassinari"), - Author("mrossi", "Mario", "Rossi"), - ) - override def by(id: AuthorId)(using Async): Try[Author] = - "PostsService" simulates s"getting author $id info..." - Try(authors.find(_.authorId == id).get) - override val service: PostsService = PostsService(contentVerifier, authorsService) + override val contentVerifier: ContentVerifier = (t, b) => Right((t, b)) + override val authorsVerifier: AuthorsVerifier = a => + require(a == authorId, "No author with the given id matches") + Author(a, "Luca", "Tassinari") + override val service: PostsService = PostsService(contentVerifier, authorsVerifier) "BlogPostsService" should "create posts correctly if author and content is legit" in { Async.blocking: blogPostsApp.service.create(authorId, postTitle, postBody).isRight shouldBe true val post = blogPostsApp.service.get(postTitle) post.isRight shouldBe true - post.toOption.get.author shouldBe blogPostsApp.authorsService.by(authorId).get + post.toOption.get.author shouldBe blogPostsApp.authorsVerifier(authorId) post.toOption.get.body shouldBe postBody } @@ -58,6 +48,7 @@ class BlogPostsServiceTest extends AnyFlatSpec with BeforeAndAfterEach: "BlogPostsService" should "fail on unauthorized author and cancel the content verification check" in { Async.blocking: - blogPostsApp.service.create("unauthorized", postTitle, postBody).isLeft shouldBe true - // TODO: check println? + val result = blogPostsApp.service.create("unauthorized", postTitle, postBody) + result.isLeft shouldBe true + // the cancelling can be observed looking at the logs :( } diff --git a/src/test/scala/io/github/tassiLuca/posts/quo/BlogPostsServiceTest.scala b/src/test/scala/io/github/tassiLuca/posts/quo/BlogPostsServiceTest.scala new file mode 100644 index 00000000..ca1848a4 --- /dev/null +++ b/src/test/scala/io/github/tassiLuca/posts/quo/BlogPostsServiceTest.scala @@ -0,0 +1,54 @@ +package io.github.tassiLuca.posts.quo + +import org.scalatest.BeforeAndAfterEach +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers.shouldBe + +import scala.concurrent.{Await, ExecutionContext} +import concurrent.duration.DurationInt + +class BlogPostsServiceTest extends AnyFlatSpec with BeforeAndAfterEach: + + private var blogPostsApp: BlogPostsApp = null + val authorId = "ltassi" + val postTitle = "A hello world post" + val postBody = "Hello World Scala Gears!" + + override def beforeEach(): Unit = + blogPostsApp = new BlogPostsApp: + override val contentVerifier: ContentVerifier = (t, b) => Right((t, b)) + override val authorsVerifier: AuthorsVerifier = a => + require(a == authorId, "No author with the given id matches") + Author(a, "Luca", "Tassinari") + override val service: PostsService = PostsService(contentVerifier, authorsVerifier) + + "BlogPostsService" should "create posts correctly if author and content is legit" in { + val creation = blogPostsApp.service.create(authorId, postTitle, postBody) + Await.ready(creation, 15.seconds) + creation.isCompleted shouldBe true + creation.value.get.isSuccess shouldBe true + val query = Await.result(blogPostsApp.service.get(postTitle), 15.seconds) + query.author shouldBe blogPostsApp.authorsVerifier(authorId) + query.body shouldBe postBody + } + + "Attempting to create two posts with same title" should "fail" in { + Await.ready(blogPostsApp.service.create(authorId, postTitle, postBody), 15.seconds) + val creation2 = blogPostsApp.service.create(authorId, postTitle, postBody) + Await.ready(creation2, 15.seconds) + creation2.value.get.isFailure shouldBe true + } + + "BlogPostsService" should "serve concurrently several requests" in { + val creation1 = blogPostsApp.service.create(authorId, postTitle, postBody) + val postTitle2 = "2nd post" + val creation2 = blogPostsApp.service.create(authorId, postTitle2, "Hello world again") + Await.result(creation1, 15.seconds).title shouldBe postTitle + Await.result(creation2, 15.seconds).title shouldBe postTitle2 + } + + "BlogPostsService" should "fail on unauthorized author and cancel the content verification check" in { + val creation = blogPostsApp.service.create("unauthorized", postTitle, postBody) + Await.ready(creation, 15.seconds) + creation.value.get.isFailure shouldBe true + }