diff --git a/realworld-app/web/src/main/kotlin/io/realworld/Application.kt b/realworld-app/web/src/main/kotlin/io/realworld/Application.kt index 9d9a125..7c0478c 100644 --- a/realworld-app/web/src/main/kotlin/io/realworld/Application.kt +++ b/realworld-app/web/src/main/kotlin/io/realworld/Application.kt @@ -1,6 +1,10 @@ package io.realworld import arrow.core.getOrElse +import arrow.effects.ForIO +import arrow.effects.IO +import arrow.effects.fix +import arrow.effects.instances.io.monadDefer.monadDefer import io.realworld.domain.common.Auth import io.realworld.domain.common.Settings import io.realworld.domain.common.Token @@ -35,10 +39,10 @@ class Application : WebMvcConfigurer { @Bean fun userCreator() = object : (Token) -> User { @Autowired - lateinit var repo: UserRepository + lateinit var repo: UserRepository override fun invoke(token: Token): User { - return repo.findById(token.id).unsafeRunSync().map { it.user }.getOrElse { throw UnauthorizedException() } + return repo.findById(token.id).fix().unsafeRunSync().map { it.user }.getOrElse { throw UnauthorizedException() } } } @@ -46,11 +50,14 @@ class Application : WebMvcConfigurer { fun auth() = Auth(settings().security) @Bean - fun userRepository(jdbcTemplate: NamedParameterJdbcTemplate) = UserRepository(jdbcTemplate) + fun userRepository(jdbcTemplate: NamedParameterJdbcTemplate) = UserRepository(jdbcTemplate, IO.monadDefer()) @Bean - fun articleRepository(jdbcTemplate: NamedParameterJdbcTemplate, userRepository: UserRepository) = ArticleRepository( - jdbcTemplate, userRepository + fun articleRepository( + jdbcTemplate: NamedParameterJdbcTemplate, + userRepository: UserRepository + ) = ArticleRepository( + jdbcTemplate, userRepository, IO.monadDefer() ) override fun addArgumentResolvers(resolvers: MutableList) { diff --git a/realworld-app/web/src/main/kotlin/io/realworld/Tx.kt b/realworld-app/web/src/main/kotlin/io/realworld/Tx.kt index a62e87c..80ffc31 100644 --- a/realworld-app/web/src/main/kotlin/io/realworld/Tx.kt +++ b/realworld-app/web/src/main/kotlin/io/realworld/Tx.kt @@ -8,14 +8,14 @@ import org.springframework.transaction.support.TransactionTemplate fun IO.runReadTx(txManager: PlatformTransactionManager): A = TransactionTemplate(txManager) .apply { isReadOnly = true - setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED) + isolationLevel = TransactionDefinition.ISOLATION_READ_COMMITTED propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW } - .execute({ this.unsafeRunSync() })!! + .execute { this.unsafeRunSync() }!! fun IO.runWriteTx(txManager: PlatformTransactionManager): A = TransactionTemplate(txManager) .apply { - setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED) + isolationLevel = TransactionDefinition.ISOLATION_READ_COMMITTED propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW } - .execute({ this.unsafeRunSync() })!! + .execute { this.unsafeRunSync() }!! diff --git a/realworld-app/web/src/main/kotlin/io/realworld/articles/Routes.kt b/realworld-app/web/src/main/kotlin/io/realworld/articles/Routes.kt index 4e8a739..0745a35 100644 --- a/realworld-app/web/src/main/kotlin/io/realworld/articles/Routes.kt +++ b/realworld-app/web/src/main/kotlin/io/realworld/articles/Routes.kt @@ -1,5 +1,9 @@ package io.realworld.articles +import arrow.effects.ForIO +import arrow.effects.IO +import arrow.effects.fix +import arrow.effects.instances.io.monadDefer.monadDefer import io.realworld.ForbiddenException import io.realworld.JwtTokenResolver import io.realworld.authHeader @@ -31,7 +35,7 @@ import io.realworld.domain.articles.GetArticlesUseCase import io.realworld.domain.articles.GetCommentsCommand import io.realworld.domain.articles.GetCommentsUseCase import io.realworld.domain.articles.GetFeedsCommand -import io.realworld.domain.articles.GetFeedsUsecase +import io.realworld.domain.articles.GetFeedsUseCase import io.realworld.domain.articles.GetTagsCommand import io.realworld.domain.articles.GetTagsUseCase import io.realworld.domain.articles.UnfavoriteArticleCommand @@ -96,25 +100,28 @@ data class TagsResponse(val tags: Set) @RestController class ArticleController( private val auth: Auth, - private val articleRepo: ArticleRepository, - private val userRepo: UserRepository, + private val articleRepo: ArticleRepository, + private val userRepo: UserRepository, private val txManager: PlatformTransactionManager ) { @PostMapping("/api/articles") fun create(@Valid @RequestBody dto: CreationDto, user: User): ResponseEntity { - val createUniqueSlugSrv = object : CreateUniqueSlugService { + val ioMonadDefer = IO.monadDefer() + val createUniqueSlugSrv = object : CreateUniqueSlugService { override val existsBySlug = articleRepo::existsBySlug + override val MD = ioMonadDefer } - return object : CreateArticleUseCase { + return object : CreateArticleUseCase { override val createUniqueSlug = createUniqueSlugSrv::slufigy override val createArticle = articleRepo::create + override val MD = ioMonadDefer }.run { CreateArticleCommand( data = dto.toDomain(), user = user ).runUseCase() - }.runWriteTx(txManager).let { + }.fix().runWriteTx(txManager).let { ResponseEntity.status(HttpStatus.CREATED).body(ArticleResponse.fromDomain(it)) } } @@ -128,15 +135,16 @@ class ArticleController( val user = JwtTokenResolver(auth::parse)( webRequest.authHeader() ).toOption().flatMap { - userRepo.findById(it.id).unsafeRunSync().map { it.user } + userRepo.findById(it.id).fix().unsafeRunSync().map { it.user } } - return object : GetArticlesUseCase { + return object : GetArticlesUseCase { override val getArticles = articleRepo::getArticles override val getArticlesCount = articleRepo::getArticlesCount + override val MD = IO.monadDefer() }.run { GetArticlesCommand(filter, user).runUseCase() - }.runReadTx(txManager).let { + }.fix().runReadTx(txManager).let { ResponseEntity.ok(ArticlesResponse.fromDomain(it)) } } @@ -146,12 +154,13 @@ class ArticleController( filter: FeedFilter, user: User ): ResponseEntity { - return object : GetFeedsUsecase { + return object : GetFeedsUseCase { override val getFeeds = articleRepo::getFeeds override val getFeedsCount = articleRepo::getFeedsCount + override val MD = IO.monadDefer() }.run { GetFeedsCommand(filter, user).runUseCase() - }.runReadTx(txManager).let { + }.fix().runReadTx(txManager).let { ResponseEntity.ok(ArticlesResponse.fromDomain(it)) } } @@ -165,14 +174,14 @@ class ArticleController( val user = JwtTokenResolver(auth::parse)( webRequest.authHeader() ).toOption().flatMap { - userRepo.findById(it.id).unsafeRunSync().map { it.user } + userRepo.findById(it.id).fix().unsafeRunSync().map { it.user } } - return object : GetArticleUseCase { + return object : GetArticleUseCase { override val getArticleBySlug = articleRepo::getBySlug }.run { GetArticleCommand(slug, user).runUseCase() - }.runReadTx(txManager).fold( + }.fix().runReadTx(txManager).fold( { ResponseEntity.notFound().build() }, { ResponseEntity.ok(ArticleResponse.fromDomain(it)) } ) @@ -183,16 +192,20 @@ class ArticleController( @PathVariable("slug") slug: String, user: User ): ResponseEntity { - return object : DeleteArticleUseCase { + return object : DeleteArticleUseCase { override val getArticleBySlug = articleRepo::getBySlug override val deleteArticle = articleRepo::deleteArticle + override val ME = IO.monadDefer() }.run { DeleteArticleCommand(slug, user).runUseCase() - }.runWriteTx(txManager).fold( + }.fix().attempt().runWriteTx(txManager).fold( { when (it) { - is ArticleDeleteError.NotAuthor -> throw ForbiddenException() - is ArticleDeleteError.NotFound -> ResponseEntity.notFound().build() + is ArticleDeleteError -> when (it) { + is ArticleDeleteError.NotAuthor -> throw ForbiddenException() + is ArticleDeleteError.NotFound -> ResponseEntity.notFound().build() + } + else -> throw it } }, { ResponseEntity.noContent().build() } @@ -205,21 +218,26 @@ class ArticleController( @Valid @RequestBody update: UpdateDto, user: User ): ResponseEntity { - val createUniqueSlugSrv = object : CreateUniqueSlugService { + val ioMonadDefer = IO.monadDefer() + val createUniqueSlugSrv = object : CreateUniqueSlugService { override val existsBySlug = articleRepo::existsBySlug + override val MD = ioMonadDefer } - val validateUpdateSrv = object : ValidateArticleUpdateService { + val validateUpdateSrv = object : ValidateArticleUpdateService { override val createUniqueSlug = createUniqueSlugSrv::slufigy override val getArticleBySlug = articleRepo::getBySlug + override val MD = ioMonadDefer } - return object : UpdateArticleUseCase { - override val validateUpdate: ValidateArticleUpdate = { x, y, z -> validateUpdateSrv.run { x.validate(y, z) } } + return object : UpdateArticleUseCase { + override val validateUpdate: ValidateArticleUpdate = + { x, y, z -> validateUpdateSrv.run { x.validate(y, z) } } override val updateArticle = articleRepo::updateArticle + override val MD = ioMonadDefer }.run { UpdateArticleCommand(update.toDomain(), slug, user).runUseCase() - }.runWriteTx(txManager).fold( + }.fix().runWriteTx(txManager).fold( { when (it) { is ArticleUpdateError.NotAuthor -> throw ForbiddenException() @@ -236,12 +254,13 @@ class ArticleController( user: User ): ResponseEntity { - return object : FavoriteUseCase { + return object : FavoriteUseCase { override val getArticleBySlug = articleRepo::getBySlug override val addFavorite = articleRepo::addFavorite + override val MD = IO.monadDefer() }.run { FavoriteArticleCommand(slug, user).runUseCase() - }.runWriteTx(txManager).fold( + }.fix().runWriteTx(txManager).fold( { when (it) { is ArticleFavoriteError.Author -> throw ForbiddenException() @@ -257,12 +276,13 @@ class ArticleController( @PathVariable("slug") slug: String, user: User ): ResponseEntity { - return object : UnfavoriteUseCase { + return object : UnfavoriteUseCase { override val getArticleBySlug = articleRepo::getBySlug override val removeFavorite = articleRepo::removeFavorite + override val MD = IO.monadDefer() }.run { UnfavoriteArticleCommand(slug, user).runUseCase() - }.runWriteTx(txManager).fold( + }.fix().runWriteTx(txManager).fold( { when (it) { is ArticleUnfavoriteError.NotFound -> ResponseEntity.notFound().build() @@ -281,15 +301,16 @@ class ArticleController( val user = JwtTokenResolver(auth::parse)( webRequest.authHeader() ).toOption().flatMap { - userRepo.findById(it.id).unsafeRunSync().map { it.user } + userRepo.findById(it.id).fix().unsafeRunSync().map { it.user } } - return object : GetCommentsUseCase { + return object : GetCommentsUseCase { override val getArticleBySlug = articleRepo::getBySlug override val getComments = articleRepo::getComments + override val MD = IO.monadDefer() }.run { GetCommentsCommand(slug, user).runUseCase() - }.runReadTx(txManager).fold( + }.fix().runReadTx(txManager).fold( { ResponseEntity.notFound().build() }, { ResponseEntity.ok(CommentsResponse.fromDomain(it)) } ) @@ -301,12 +322,13 @@ class ArticleController( @Valid @RequestBody comment: CommentDto, user: User ): ResponseEntity { - return object : CommentUseCase { + return object : CommentUseCase { override val getArticleBySlug = articleRepo::getBySlug override val addComment = articleRepo::addComment + override val MD = IO.monadDefer() }.run { CommentArticleCommand(slug, comment.body, user).runUsecase() - }.runWriteTx(txManager).fold( + }.fix().runWriteTx(txManager).fold( { when (it) { is ArticleCommentError.NotFound -> ResponseEntity.notFound().build() @@ -322,13 +344,14 @@ class ArticleController( @PathVariable("id") commentId: Long, user: User ): ResponseEntity { - return object : DeleteCommentUseCase { + return object : DeleteCommentUseCase { override val getArticleBySlug = articleRepo::getBySlug override val deleteComment = articleRepo::deleteComment override val getComment = articleRepo::getComment + override val MD = IO.monadDefer() }.run { DeleteCommentCommand(slug, commentId, user).runUseCase() - }.runWriteTx(txManager).fold( + }.fix().runWriteTx(txManager).fold( { when (it) { is ArticleCommentDeleteError.ArticleNotFound -> ResponseEntity.notFound().build() @@ -342,11 +365,11 @@ class ArticleController( @GetMapping("/api/tags") fun getTags(): ResponseEntity { - return object : GetTagsUseCase { + return object : GetTagsUseCase { override val getTags = articleRepo::getTags }.run { GetTagsCommand.runUseCase() - }.runReadTx(txManager).let { + }.fix().runReadTx(txManager).let { ResponseEntity.ok(TagsResponse(it)) } } diff --git a/realworld-app/web/src/main/kotlin/io/realworld/profiles/Routes.kt b/realworld-app/web/src/main/kotlin/io/realworld/profiles/Routes.kt index 75974a5..131505f 100644 --- a/realworld-app/web/src/main/kotlin/io/realworld/profiles/Routes.kt +++ b/realworld-app/web/src/main/kotlin/io/realworld/profiles/Routes.kt @@ -1,5 +1,7 @@ package io.realworld.profiles +import arrow.effects.ForIO +import arrow.effects.fix import io.realworld.JwtTokenResolver import io.realworld.authHeader import io.realworld.domain.common.Auth @@ -32,9 +34,8 @@ data class ProfileResponse(val profile: ProfileResponseDto) { @RestController class ProfileController( private val auth: Auth, - private val repo: UserRepository, + private val repo: UserRepository, private val txManager: PlatformTransactionManager - ) { @GetMapping("/api/profiles/{username}") fun getProfile( @@ -45,15 +46,16 @@ class ProfileController( val user = JwtTokenResolver(auth::parse)( webRequest.authHeader() ).toOption().flatMap { - repo.findById(it.id).unsafeRunSync().map { it.user } + repo.findById(it.id).fix().unsafeRunSync().map { it.user } } - return object : GetProfileUseCase { + return object : GetProfileUseCase { override val getUser = repo::findByUsername override val hasFollower = repo::hasFollower + override val M = repo.MD }.run { GetProfileCommand(username, user).runUseCase() - }.runReadTx(txManager).fold( + }.fix().runReadTx(txManager).fold( { ResponseEntity.notFound().build() }, { ResponseEntity.ok(ProfileResponse.fromDomain(it)) } ) @@ -64,12 +66,13 @@ class ProfileController( @PathVariable("username") username: String, current: User ): ResponseEntity { - return object : FollowUseCase { + return object : FollowUseCase { override val addFollower = repo::addFollower override val getUser = repo::findByUsername + override val M = repo.MD }.run { FollowCommand(username, current).runUseCase() - }.runWriteTx(txManager).fold( + }.fix().runWriteTx(txManager).fold( { ResponseEntity.notFound().build() }, { ResponseEntity.ok(ProfileResponse.fromDomain(it)) } ) @@ -80,12 +83,13 @@ class ProfileController( @PathVariable("username") username: String, current: User ): ResponseEntity { - return object : UnfollowUseCase { + return object : UnfollowUseCase { override val getUser = repo::findByUsername override val removeFollower = repo::removeFollower + override val M = repo.MD }.run { UnfollowCommand(username, current).runUseCase() - }.runWriteTx(txManager).fold( + }.fix().runWriteTx(txManager).fold( { ResponseEntity.notFound().build() }, { ResponseEntity.ok(ProfileResponse.fromDomain(it)) } ) diff --git a/realworld-app/web/src/main/kotlin/io/realworld/users/Routes.kt b/realworld-app/web/src/main/kotlin/io/realworld/users/Routes.kt index e5eee13..8b6ce11 100644 --- a/realworld-app/web/src/main/kotlin/io/realworld/users/Routes.kt +++ b/realworld-app/web/src/main/kotlin/io/realworld/users/Routes.kt @@ -1,6 +1,8 @@ package io.realworld.users import arrow.core.Option +import arrow.effects.ForIO +import arrow.effects.fix import io.realworld.FieldError import io.realworld.UnauthorizedException import io.realworld.domain.common.Auth @@ -43,7 +45,7 @@ data class UserResponse(val user: UserResponseDto) { @RestController class UserController( private val auth0: Auth, - private val repo: UserRepository, + private val repo: UserRepository, private val txManager: PlatformTransactionManager ) { @@ -52,23 +54,25 @@ class UserController( @PostMapping("/api/users") fun register(@Valid @RequestBody registration: RegistrationDto): ResponseEntity { - val validateUserSrv = object : ValidateUserService { + val validateUserSrv = object : ValidateUserService { override val auth = auth0 override val existsByEmail = repo::existsByEmail override val existsByUsername = repo::existsByUsername + override val M = repo.MD } - return object : RegisterUserUseCase { + return object : RegisterUserUseCase { override val auth = auth0 - override val createUser: CreateUser = repo::create - override val validateUser: ValidateUserRegistration = { x -> validateUserSrv.run { x.validate() } } + override val createUser: CreateUser = repo::create + override val validateUser: ValidateUserRegistration = { x -> validateUserSrv.run { x.validate() } } + override val M = repo.MD }.run { RegisterUserCommand(UserRegistration( username = registration.username, email = registration.email, password = registration.password )).runUseCase() - }.runWriteTx(txManager).fold( + }.fix().runWriteTx(txManager).fold( { when (it) { is UserRegistrationError.EmailAlreadyTaken -> @@ -83,15 +87,16 @@ class UserController( @PostMapping("/api/users/login") fun login(@Valid @RequestBody login: LoginDto): ResponseEntity { - return object : LoginUserUseCase { + return object : LoginUserUseCase { override val auth = auth0 - override val getUser: GetUserByEmail = repo::findByEmail + override val getUser: GetUserByEmail = repo::findByEmail + override val M = repo.MD }.run { LoginUserCommand( email = login.email, password = login.password ).runUseCase() - }.runWriteTx(txManager).fold( + }.fix().runWriteTx(txManager).fold( { throw UnauthorizedException() }, { ResponseEntity.ok().body(UserResponse.fromDomain(it)) } ) @@ -99,16 +104,18 @@ class UserController( @PutMapping("/api/user") fun update(@Valid @RequestBody update: UserUpdateDto, user: User): ResponseEntity { - val validateUpdateSrv = object : ValidateUserUpdateService { + val validateUpdateSrv = object : ValidateUserUpdateService { override val auth = auth0 override val existsByEmail = repo::existsByEmail override val existsByUsername = repo::existsByUsername + override val M = repo.MD } - return object : UpdateUserUseCase { + return object : UpdateUserUseCase { override val auth = auth0 - override val validateUpdate: ValidateUserUpdate = { x, y -> validateUpdateSrv.run { x.validate(y) } } - override val updateUser: UpdateUser = repo::update + override val validateUpdate: ValidateUserUpdate = { x, y -> validateUpdateSrv.run { x.validate(y) } } + override val updateUser: UpdateUser = repo::update + override val M = repo.MD }.run { UpdateUserCommand( data = UserUpdate( @@ -120,7 +127,7 @@ class UserController( ), current = user ).runUseCase() - }.runWriteTx(txManager).fold( + }.fix().runWriteTx(txManager).fold( { when (it) { is UserUpdateError.EmailAlreadyTaken -> diff --git a/realworld-app/web/src/test/kotlin/io/realworld/ArticleTests.kt b/realworld-app/web/src/test/kotlin/io/realworld/ArticleTests.kt index b7cb74f..1e21318 100644 --- a/realworld-app/web/src/test/kotlin/io/realworld/ArticleTests.kt +++ b/realworld-app/web/src/test/kotlin/io/realworld/ArticleTests.kt @@ -1,5 +1,7 @@ package io.realworld +import arrow.effects.ForIO +import arrow.effects.fix import io.realworld.articles.ArticleResponse import io.realworld.articles.ArticleResponseDto import io.realworld.articles.ArticlesResponse @@ -181,7 +183,7 @@ class ArticleTests { lateinit var auth: Auth @Autowired - lateinit var userRepo: UserRepository + lateinit var userRepo: UserRepository lateinit var spec: RequestSpecification lateinit var fixtures: FixtureFactory @@ -215,16 +217,14 @@ class ArticleTests { .then() .statusCode(201) .toDto().apply { - assertThat(article).isEqualToIgnoringGivenFields(expected, "createdAt", "updatedAt") - assertThat(article.createdAt).isNotNull() - assertThat(article.createdAt).isEqualTo(article.updatedAt) + article.assert(expected) } client.get("/api/articles/${expected.title.slugify()}") .then() .statusCode(200) .toDto().apply { - assertThat(article).isEqualToIgnoringGivenFields(expected, "createdAt", "updatedAt") + article.assert(expected) } } @@ -948,7 +948,7 @@ class ArticleTests { } private fun createUser(user: TestUser) = - userRepo.create(fixtures.validTestUserRegistration(user.username, user.email)).unsafeRunSync() + userRepo.create(fixtures.validTestUserRegistration(user.username, user.email)).fix().unsafeRunSync() private fun UserClient.Companion.from(user: TestUser) = createUser(user).let { UserClient(it, ApiClient(spec, it.token)) } diff --git a/realworld-app/web/src/test/kotlin/io/realworld/CommentTests.kt b/realworld-app/web/src/test/kotlin/io/realworld/CommentTests.kt index dd6524d..8382e36 100644 --- a/realworld-app/web/src/test/kotlin/io/realworld/CommentTests.kt +++ b/realworld-app/web/src/test/kotlin/io/realworld/CommentTests.kt @@ -1,5 +1,7 @@ package io.realworld +import arrow.effects.ForIO +import arrow.effects.fix import io.realworld.articles.CommentDto import io.realworld.articles.CommentResponse import io.realworld.domain.common.Auth @@ -43,13 +45,13 @@ class CommentTests { lateinit var jdbcTemplate: JdbcTemplate @Autowired - lateinit var articleRepo: ArticleRepository + lateinit var articleRepo: ArticleRepository @Autowired lateinit var auth: Auth @Autowired - lateinit var userRepo: UserRepository + lateinit var userRepo: UserRepository lateinit var spec: RequestSpecification lateinit var fixtures: FixtureFactory @@ -77,7 +79,7 @@ class CommentTests { val cheeta = createUser("cheeta") val jane = createUser("jane") - val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).unsafeRunSync() + val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).fix().unsafeRunSync() val cheetaClient = ApiClient(spec, cheeta.token) cheetaClient.post("/api/articles/${janesArticle.slug}/comments", TestComments.Jacobian.req) @@ -98,7 +100,7 @@ class CommentTests { fun `comment article, author`() { val jane = createUser("jane") - val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).unsafeRunSync() + val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).fix().unsafeRunSync() val janeClient = ApiClient(spec, jane.token) janeClient.post("/api/articles/${janesArticle.slug}/comments", TestComments.Jacobian.req) @@ -111,7 +113,7 @@ class CommentTests { val cheeta = createUser("cheeta") val jane = createUser("jane") - val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).unsafeRunSync() + val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).fix().unsafeRunSync() val cheetaClient = ApiClient(spec, cheeta.token) @@ -128,7 +130,7 @@ class CommentTests { @Test fun `comment article requires auth`() { val jane = createUser("jane") - val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).unsafeRunSync() + val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).fix().unsafeRunSync() ApiClient(spec).post("/api/articles/${janesArticle.slug}/comments", TestComments.Jacobian.req) .then() @@ -140,7 +142,7 @@ class CommentTests { val cheeta = createUser("cheeta") val jane = createUser("jane") - val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).unsafeRunSync() + val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).fix().unsafeRunSync() val cheetaClient = ApiClient(spec, cheeta.token) @@ -160,7 +162,7 @@ class CommentTests { val cheeta = createUser("cheeta") val jane = createUser("jane") - val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).unsafeRunSync() + val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).fix().unsafeRunSync() val cheetaClient = ApiClient(spec, cheeta.token) @@ -180,7 +182,7 @@ class CommentTests { val cheeta = createUser("cheeta") val jane = createUser("jane") - val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).unsafeRunSync() + val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).fix().unsafeRunSync() val cheetaClient = ApiClient(spec, cheeta.token) cheetaClient.delete("/api/articles/${janesArticle.slug}/comments/${Long.MAX_VALUE}") @@ -193,7 +195,7 @@ class CommentTests { val cheeta = createUser("cheeta") val jane = createUser("jane") - val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).unsafeRunSync() + val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).fix().unsafeRunSync() val janeClient = ApiClient(spec, jane.token) val cheetaClient = ApiClient(spec, cheeta.token) @@ -217,7 +219,7 @@ class CommentTests { val jane = createUser("jane") val tarzan = createUser("tarzan") - val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).unsafeRunSync() + val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).fix().unsafeRunSync() val tarzanClient = ApiClient(spec, tarzan.token) tarzanClient.get("/api/articles/${janesArticle.slug}/comments") @@ -258,5 +260,5 @@ class CommentTests { } private fun createUser(username: String) = - userRepo.create(fixtures.validTestUserRegistration(username, "$username@realworld.io")).unsafeRunSync() + userRepo.create(fixtures.validTestUserRegistration(username, "$username@realworld.io")).fix().unsafeRunSync() } diff --git a/realworld-app/web/src/test/kotlin/io/realworld/FavoriteTests.kt b/realworld-app/web/src/test/kotlin/io/realworld/FavoriteTests.kt index 23ac9c0..fce9a90 100644 --- a/realworld-app/web/src/test/kotlin/io/realworld/FavoriteTests.kt +++ b/realworld-app/web/src/test/kotlin/io/realworld/FavoriteTests.kt @@ -1,5 +1,7 @@ package io.realworld +import arrow.effects.ForIO +import arrow.effects.fix import io.realworld.articles.UpdateDto import io.realworld.domain.common.Auth import io.realworld.persistence.ArticleRepository @@ -34,13 +36,13 @@ class FavoriteTests { lateinit var jdbcTemplate: JdbcTemplate @Autowired - lateinit var articleRepo: ArticleRepository + lateinit var articleRepo: ArticleRepository @Autowired lateinit var auth: Auth @Autowired - lateinit var userRepo: UserRepository + lateinit var userRepo: UserRepository lateinit var spec: RequestSpecification lateinit var fixtures: FixtureFactory @@ -69,7 +71,7 @@ class FavoriteTests { val jane = createUser("jane") val tarzan = createUser("tarzan") - val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).unsafeRunSync() + val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).fix().unsafeRunSync() val tarzanClient = ApiClient(spec, tarzan.token) tarzanClient.get("/api/articles/${janesArticle.slug}") @@ -116,7 +118,7 @@ class FavoriteTests { @Test fun `favorite article, author`() { val jane = createUser("jane") - val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).unsafeRunSync() + val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).fix().unsafeRunSync() val janeClient = ApiClient(spec, jane.token) janeClient.get("/api/articles/${janesArticle.slug}") @@ -137,7 +139,7 @@ class FavoriteTests { @Test fun `favoriting already favorited acticle has no effect`() { val jane = createUser("jane") - val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).unsafeRunSync() + val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).fix().unsafeRunSync() val tarzan = createUser("tarzan") val tarzanClient = ApiClient(spec, tarzan.token) @@ -169,7 +171,7 @@ class FavoriteTests { val jane = createUser("jane") val tarzan = createUser("tarzan") - val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).unsafeRunSync() + val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).fix().unsafeRunSync() val tarzanClient = ApiClient(spec, tarzan.token) tarzanClient.post("/api/articles/${janesArticle.slug}/favorite") @@ -213,7 +215,7 @@ class FavoriteTests { val janeClient = ApiClient(spec, jane.token) val tarzanClient = ApiClient(spec, tarzan.token) - val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).unsafeRunSync() + val janesArticle = articleRepo.create(fixtures.validTestArticleCreation(), jane).fix().unsafeRunSync() val updateReq = UpdateRequest(UpdateDto(description = "updated.${janesArticle.description}")) janeClient.put("/api/articles/${janesArticle.slug}", updateReq) @@ -257,5 +259,5 @@ class FavoriteTests { } private fun createUser(username: String) = - userRepo.create(fixtures.validTestUserRegistration(username, "$username@realworld.io")).unsafeRunSync() + userRepo.create(fixtures.validTestUserRegistration(username, "$username@realworld.io")).fix().unsafeRunSync() } diff --git a/realworld-app/web/src/test/kotlin/io/realworld/ProfileTests.kt b/realworld-app/web/src/test/kotlin/io/realworld/ProfileTests.kt index 8ecd206..612bb80 100644 --- a/realworld-app/web/src/test/kotlin/io/realworld/ProfileTests.kt +++ b/realworld-app/web/src/test/kotlin/io/realworld/ProfileTests.kt @@ -1,5 +1,7 @@ package io.realworld +import arrow.effects.ForIO +import arrow.effects.fix import io.realworld.domain.common.Auth import io.realworld.persistence.UserRepository import io.realworld.persistence.UserTbl @@ -35,7 +37,7 @@ class ProfileTests { lateinit var auth: Auth @Autowired - lateinit var userRepo: UserRepository + lateinit var userRepo: UserRepository lateinit var spec: RequestSpecification lateinit var fixtures: FixtureFactory @@ -63,12 +65,12 @@ class ProfileTests { val user1 = fixtures.validTestUserRegistration("foo", "foo@realworld.io") val user2 = fixtures.validTestUserRegistration("bar", "bar@realworld.io") val user3 = fixtures.validTestUserRegistration("baz", "baz@realworld.io") - userRepo.create(user1).unsafeRunSync() - userRepo.create(user2).unsafeRunSync() - userRepo.create(user3).unsafeRunSync() + userRepo.create(user1).fix().unsafeRunSync() + userRepo.create(user2).fix().unsafeRunSync() + userRepo.create(user3).fix().unsafeRunSync() - userRepo.addFollower(user2.id, user1.id).unsafeRunSync() - userRepo.addFollower(user3.id, user1.id).unsafeRunSync() + userRepo.addFollower(user2.id, user1.id).fix().unsafeRunSync() + userRepo.addFollower(user3.id, user1.id).fix().unsafeRunSync() val user1Client = ApiClient(spec, user1.token) val user2Client = ApiClient(spec, user2.token) @@ -101,10 +103,10 @@ class ProfileTests { fun `get profile without token`() { val user1 = fixtures.validTestUserRegistration("foo", "foo@realworld.io") val user2 = fixtures.validTestUserRegistration("bar", "bar@realworld.io") - userRepo.create(user1).unsafeRunSync() - userRepo.create(user2).unsafeRunSync() + userRepo.create(user1).fix().unsafeRunSync() + userRepo.create(user2).fix().unsafeRunSync() - userRepo.addFollower(user1.id, user2.id).unsafeRunSync() + userRepo.addFollower(user1.id, user2.id).fix().unsafeRunSync() ApiClient(spec).get("/api/profiles/bar") .then() @@ -117,8 +119,8 @@ class ProfileTests { fun `follow`() { val user1 = fixtures.validTestUserRegistration("foo", "foo@realworld.io") val user2 = fixtures.validTestUserRegistration("bar", "bar@realworld.io") - userRepo.create(user1).unsafeRunSync() - userRepo.create(user2).unsafeRunSync() + userRepo.create(user1).fix().unsafeRunSync() + userRepo.create(user2).fix().unsafeRunSync() val client = ApiClient(spec, user1.token) @@ -138,7 +140,7 @@ class ProfileTests { @Test fun `follow phantom`() { val user1 = fixtures.validTestUserRegistration("foo", "foo@realworld.io") - userRepo.create(user1).unsafeRunSync() + userRepo.create(user1).fix().unsafeRunSync() ApiClient(spec, user1.token).post("/api/profiles/bar/follow") .then() @@ -149,9 +151,9 @@ class ProfileTests { fun `follow already followed`() { val user1 = fixtures.validTestUserRegistration("foo", "foo@realworld.io") val user2 = fixtures.validTestUserRegistration("bar", "bar@realworld.io") - userRepo.create(user1).unsafeRunSync() - userRepo.create(user2).unsafeRunSync() - userRepo.addFollower(user2.id, user1.id).unsafeRunSync() + userRepo.create(user1).fix().unsafeRunSync() + userRepo.create(user2).fix().unsafeRunSync() + userRepo.addFollower(user2.id, user1.id).fix().unsafeRunSync() ApiClient(spec, user1.token).post("/api/profiles/bar/follow") .then() @@ -162,8 +164,8 @@ class ProfileTests { fun `unfollow`() { val user1 = fixtures.validTestUserRegistration("foo", "foo@realworld.io") val user2 = fixtures.validTestUserRegistration("bar", "bar@realworld.io") - userRepo.create(user1).unsafeRunSync() - userRepo.create(user2).unsafeRunSync() + userRepo.create(user1).fix().unsafeRunSync() + userRepo.create(user2).fix().unsafeRunSync() val client = ApiClient(spec, user1.token) @@ -183,7 +185,7 @@ class ProfileTests { @Test fun `unfollow phantom`() { val user1 = fixtures.validTestUserRegistration("foo", "foo@realworld.io") - userRepo.create(user1).unsafeRunSync() + userRepo.create(user1).fix().unsafeRunSync() ApiClient(spec, user1.token).delete("/api/profiles/bar/follow") .then() @@ -194,8 +196,8 @@ class ProfileTests { fun `unfollow not followed`() { val user1 = fixtures.validTestUserRegistration("foo", "foo@realworld.io") val user2 = fixtures.validTestUserRegistration("bar", "bar@realworld.io") - userRepo.create(user1).unsafeRunSync() - userRepo.create(user2).unsafeRunSync() + userRepo.create(user1).fix().unsafeRunSync() + userRepo.create(user2).fix().unsafeRunSync() ApiClient(spec, user1.token).delete("/api/profiles/bar/follow") .then() diff --git a/realworld-app/web/src/test/kotlin/io/realworld/UserTests.kt b/realworld-app/web/src/test/kotlin/io/realworld/UserTests.kt index ba7acd8..899a503 100644 --- a/realworld-app/web/src/test/kotlin/io/realworld/UserTests.kt +++ b/realworld-app/web/src/test/kotlin/io/realworld/UserTests.kt @@ -1,5 +1,7 @@ package io.realworld +import arrow.effects.ForIO +import arrow.effects.fix import io.realworld.domain.common.Auth import io.realworld.persistence.UserRepository import io.realworld.persistence.UserTbl @@ -47,7 +49,7 @@ class UserTests { @Autowired lateinit var auth: Auth - @SpyBean lateinit var userRepo: UserRepository + @SpyBean lateinit var userRepo: UserRepository lateinit var spec: RequestSpecification lateinit var fixtures: FixtureFactory @@ -102,7 +104,7 @@ class UserTests { @Test fun `cannot register already existing username`() { - userRepo.create(fixtures.validTestUserRegistration(TestUser.username, TestUser.email)).unsafeRunSync() + userRepo.create(fixtures.validTestUserRegistration(TestUser.username, TestUser.email)).fix().unsafeRunSync() val regReq = RegistrationRequest(RegistrationDto( username = TestUser.username, email = "unique.${TestUser.email}", @@ -116,7 +118,7 @@ class UserTests { @Test fun `cannot register already existing email`() { - userRepo.create(fixtures.validTestUserRegistration(TestUser.username, TestUser.email)).unsafeRunSync() + userRepo.create(fixtures.validTestUserRegistration(TestUser.username, TestUser.email)).fix().unsafeRunSync() val regReq = RegistrationRequest(RegistrationDto( username = "unique", email = TestUser.email, @@ -157,7 +159,7 @@ class UserTests { @Test fun `current user is resolved from token`() { val registered = fixtures.validTestUserRegistration(TestUser.username, TestUser.email) - userRepo.create(registered).unsafeRunSync() + userRepo.create(registered).fix().unsafeRunSync() val actual: UserResponse = ApiClient(spec).get("/api/user", registered.token) .then() @@ -179,7 +181,7 @@ class UserTests { @Test fun `invalid password is reported as 401`() { val registered = fixtures.validTestUserRegistration(TestUser.username, TestUser.email) - userRepo.create(registered).unsafeRunSync() + userRepo.create(registered).fix().unsafeRunSync() with(LoginRequest(LoginDto(email = registered.email, password = "invalid"))) { ApiClient(spec).post("/api/users/login", this).then().statusCode(401) @@ -189,7 +191,7 @@ class UserTests { @Test fun `update user email`() { val registered = fixtures.validTestUserRegistration(TestUser.username, TestUser.email) - userRepo.create(registered).unsafeRunSync() + userRepo.create(registered).fix().unsafeRunSync() UserUpdateRequest(UserUpdateDto(email = "updated.${registered.email}")).apply { val actual: UserResponse = ApiClient(spec).put("/api/user", this, registered.token) @@ -203,7 +205,7 @@ class UserTests { @Test fun `update user password`() { val registered = fixtures.validTestUserRegistration(TestUser.username, TestUser.email) - userRepo.create(registered).unsafeRunSync() + userRepo.create(registered).fix().unsafeRunSync() val client = ApiClient(spec) @@ -218,7 +220,7 @@ class UserTests { @Test fun `update user username`() { val registered = fixtures.validTestUserRegistration(TestUser.username, TestUser.email) - userRepo.create(registered).unsafeRunSync() + userRepo.create(registered).fix().unsafeRunSync() UserUpdateRequest(UserUpdateDto(username = "updated.${registered.username}")).apply { val actual: UserResponse = ApiClient(spec).put("/api/user", this, registered.token) @@ -232,7 +234,7 @@ class UserTests { @Test fun `update user username, image, bio`() { val registered = fixtures.validTestUserRegistration(TestUser.username, TestUser.email) - userRepo.create(registered).unsafeRunSync() + userRepo.create(registered).fix().unsafeRunSync() UserUpdateRequest(UserUpdateDto( username = "updated.${registered.username}", diff --git a/realworld-domain/src/main/kotlin/io/realworld/domain/articles/Dependencies.kt b/realworld-domain/src/main/kotlin/io/realworld/domain/articles/Dependencies.kt index 24f0474..ab7c92c 100644 --- a/realworld-domain/src/main/kotlin/io/realworld/domain/articles/Dependencies.kt +++ b/realworld-domain/src/main/kotlin/io/realworld/domain/articles/Dependencies.kt @@ -1,33 +1,33 @@ package io.realworld.domain.articles +import arrow.Kind import arrow.core.Either import arrow.core.Option -import arrow.effects.IO import io.realworld.domain.users.User -typealias CreateArticle = (ValidArticleCreation, User) -> IO
+typealias CreateArticle = (ValidArticleCreation, User) -> Kind -typealias ValidateArticleUpdate = (ArticleUpdate, slug: String, User) -> - IO> -typealias UpdateArticle = (ValidArticleUpdate, User) -> IO
+typealias ValidateArticleUpdate = (ArticleUpdate, slug: String, User) -> + Kind> +typealias UpdateArticle = (ValidArticleUpdate, User) -> Kind -typealias CreateUniqueSlug = (title: String) -> IO -typealias ExistsBySlug = (slug: String) -> IO +typealias CreateUniqueSlug = (title: String) -> Kind +typealias ExistsBySlug = (slug: String) -> Kind -typealias GetArticleBySlug = (slug: String, Option) -> IO> -typealias GetArticles = (ArticleFilter, Option) -> IO> -typealias GetArticlesCount = (ArticleFilter) -> IO -typealias GetFeeds = (FeedFilter, User) -> IO> -typealias GetFeedsCount = (user: User) -> IO +typealias GetArticleBySlug = (slug: String, Option) -> Kind> +typealias GetArticles = (ArticleFilter, Option) -> Kind> +typealias GetArticlesCount = (ArticleFilter) -> Kind +typealias GetFeeds = (FeedFilter, User) -> Kind> +typealias GetFeedsCount = (user: User) -> Kind -typealias DeleteArticle = (ArticleId) -> IO +typealias DeleteArticle = (ArticleId) -> Kind -typealias AddFavorite = (ArticleId, User) -> IO -typealias RemoveFavorite = (ArticleId, User) -> IO +typealias AddFavorite = (ArticleId, User) -> Kind +typealias RemoveFavorite = (ArticleId, User) -> Kind -typealias AddComment = (ArticleId, comment: String, User) -> IO -typealias DeleteComment = (id: Long) -> IO -typealias GetComment = (id: Long, User) -> IO> -typealias GetComments = (ArticleId, Option) -> IO> +typealias AddComment = (ArticleId, comment: String, User) -> Kind +typealias DeleteComment = (id: Long) -> Kind +typealias GetComment = (id: Long, User) -> Kind> +typealias GetComments = (ArticleId, Option) -> Kind> -typealias GetTags = () -> IO> +typealias GetTags = () -> Kind> diff --git a/realworld-domain/src/main/kotlin/io/realworld/domain/articles/Services.kt b/realworld-domain/src/main/kotlin/io/realworld/domain/articles/Services.kt index 3e6dfd1..b1493b0 100644 --- a/realworld-domain/src/main/kotlin/io/realworld/domain/articles/Services.kt +++ b/realworld-domain/src/main/kotlin/io/realworld/domain/articles/Services.kt @@ -1,13 +1,12 @@ package io.realworld.domain.articles +import arrow.Kind import arrow.core.Either import arrow.core.getOrElse import arrow.core.left import arrow.core.right import arrow.core.some -import arrow.effects.IO -import arrow.effects.fix -import arrow.effects.instances.io.monad.monad +import arrow.effects.typeclasses.MonadDefer import arrow.typeclasses.binding import com.github.slugify.Slugify import io.realworld.domain.users.User @@ -16,26 +15,28 @@ import java.util.UUID private val slugifier = Slugify() fun String.slugify() = slugifier.slugify(this) -interface CreateUniqueSlugService { - val existsBySlug: ExistsBySlug +interface CreateUniqueSlugService { + val existsBySlug: ExistsBySlug + val MD: MonadDefer - fun slufigy(s: String): IO = IO.monad().binding { + fun slufigy(s: String): Kind = MD.binding { val slugified = s.slugify() var slugCandidate = slugified while (existsBySlug(slugCandidate).bind()) { slugCandidate = "$slugified-${UUID.randomUUID().toString().substring(0, 8)}" } slugCandidate - }.fix() + } } -interface ValidateArticleUpdateService { - val createUniqueSlug: CreateUniqueSlug - val getArticleBySlug: GetArticleBySlug +interface ValidateArticleUpdateService { + val createUniqueSlug: CreateUniqueSlug + val getArticleBySlug: GetArticleBySlug + val MD: MonadDefer - fun ArticleUpdate.validate(slug: String, user: User): IO> { + fun ArticleUpdate.validate(slug: String, user: User): Kind> { val cmd = this - return IO.monad().binding { + return MD.binding { getArticleBySlug(slug, user.some()).bind().fold( { ArticleUpdateError.NotFound.left() }, { @@ -53,6 +54,6 @@ interface ValidateArticleUpdateService { } } ) - }.fix() + } } } diff --git a/realworld-domain/src/main/kotlin/io/realworld/domain/articles/UseCases.kt b/realworld-domain/src/main/kotlin/io/realworld/domain/articles/UseCases.kt index b257736..77c9bed 100644 --- a/realworld-domain/src/main/kotlin/io/realworld/domain/articles/UseCases.kt +++ b/realworld-domain/src/main/kotlin/io/realworld/domain/articles/UseCases.kt @@ -1,5 +1,6 @@ package io.realworld.domain.articles +import arrow.Kind import arrow.core.Either import arrow.core.Option import arrow.core.getOrElse @@ -9,11 +10,9 @@ import arrow.core.right import arrow.core.some import arrow.data.EitherT import arrow.data.value -import arrow.effects.ForIO -import arrow.effects.IO -import arrow.effects.fix -import arrow.effects.instances.io.monad.monad +import arrow.effects.typeclasses.MonadDefer import arrow.instances.monad +import arrow.typeclasses.MonadError import arrow.typeclasses.binding import io.realworld.domain.users.User import java.util.UUID @@ -36,7 +35,7 @@ sealed class ArticleUpdateError { object NotFound : ArticleUpdateError() } -sealed class ArticleDeleteError { +sealed class ArticleDeleteError : RuntimeException() { object NotAuthor : ArticleDeleteError() object NotFound : ArticleDeleteError() } @@ -60,13 +59,14 @@ sealed class ArticleCommentDeleteError { object NotAuthor : ArticleCommentDeleteError() } -interface CreateArticleUseCase { - val createUniqueSlug: CreateUniqueSlug - val createArticle: CreateArticle +interface CreateArticleUseCase { + val createUniqueSlug: CreateUniqueSlug + val createArticle: CreateArticle + val MD: MonadDefer - fun CreateArticleCommand.runUseCase(): IO
{ + fun CreateArticleCommand.runUseCase(): Kind { val cmd = this - return IO.monad().binding { + return MD.binding { val slug = createUniqueSlug(cmd.data.title).bind() createArticle( ValidArticleCreation( @@ -79,42 +79,44 @@ interface CreateArticleUseCase { ), cmd.user ).bind() - }.fix() + } } } -interface GetArticleUseCase { - val getArticleBySlug: GetArticleBySlug +interface GetArticleUseCase { + val getArticleBySlug: GetArticleBySlug - fun GetArticleCommand.runUseCase(): IO> = + fun GetArticleCommand.runUseCase(): Kind> = getArticleBySlug(slug, user) } -interface DeleteArticleUseCase { - val getArticleBySlug: GetArticleBySlug - val deleteArticle: DeleteArticle +interface DeleteArticleUseCase { + val getArticleBySlug: GetArticleBySlug + val deleteArticle: DeleteArticle + val ME: MonadError - fun DeleteArticleCommand.runUseCase(): IO> { + fun DeleteArticleCommand.runUseCase(): Kind { val cmd = this - return IO.monad().binding { + return ME.binding { getArticleBySlug(cmd.slug, user.some()).bind().fold( - { ArticleDeleteError.NotFound.left() }, + { ME.raiseError(ArticleDeleteError.NotFound) }, { - if (it.author.username != cmd.user.username) ArticleDeleteError.NotAuthor.left() - else deleteArticle(it.id).bind().right() + if (it.author.username != cmd.user.username) ME.raiseError(ArticleDeleteError.NotAuthor) + else deleteArticle(it.id) } - ) - }.fix() + ).bind() + } } } -interface GetArticlesUseCase { - val getArticles: GetArticles - val getArticlesCount: GetArticlesCount +interface GetArticlesUseCase { + val getArticles: GetArticles + val getArticlesCount: GetArticlesCount + val MD: MonadDefer - fun GetArticlesCommand.runUseCase(): IO, Long>> { + fun GetArticlesCommand.runUseCase(): Kind, Long>> { val cmd = this - return IO.monad().binding { + return MD.binding { val count = getArticlesCount(cmd.filter).bind() if (count == 0L) Pair(listOf(), 0L) @@ -122,48 +124,52 @@ interface GetArticlesUseCase { val articles = getArticles(cmd.filter, cmd.user).bind() Pair(articles, count) } - }.fix() + } } } -interface GetFeedsUsecase { - val getFeeds: GetFeeds - val getFeedsCount: GetFeedsCount +interface GetFeedsUseCase { + val getFeeds: GetFeeds + val getFeedsCount: GetFeedsCount + val MD: MonadDefer - fun GetFeedsCommand.runUseCase(): IO, Long>> { + fun GetFeedsCommand.runUseCase(): Kind, Long>> { val cmd = this - return IO.monad().binding { + return MD.binding { val count = getFeedsCount(cmd.user).bind() - if (count == 0L) - Pair(listOf(), 0L) + if (count == 0L) Pair(listOf(), 0L) else { val feeds = getFeeds(cmd.filter, cmd.user).bind() Pair(feeds, count) } - }.fix() + } } } -interface UpdateArticleUseCase { - val validateUpdate: ValidateArticleUpdate - val updateArticle: UpdateArticle +interface UpdateArticleUseCase { + val validateUpdate: ValidateArticleUpdate + val updateArticle: UpdateArticle + val MD: MonadDefer - fun UpdateArticleCommand.runUseCase(): IO> { + fun UpdateArticleCommand.runUseCase(): Kind> { val cmd = this - return EitherT.monad(IO.monad()).binding { + return EitherT.monad(MD).binding { val validUpdate = EitherT(validateUpdate(cmd.data, cmd.slug, cmd.user)).bind() - EitherT(updateArticle(validUpdate, cmd.user).map { it.right() }).bind() - }.value().fix() + EitherT( + MD.run { updateArticle(validUpdate, cmd.user).map { it.right() } } + ).bind() + }.value() } } -interface FavoriteUseCase { - val getArticleBySlug: GetArticleBySlug - val addFavorite: AddFavorite +interface FavoriteUseCase { + val getArticleBySlug: GetArticleBySlug + val addFavorite: AddFavorite + val MD: MonadDefer - fun FavoriteArticleCommand.runUseCase(): IO> { + fun FavoriteArticleCommand.runUseCase(): Kind> { val cmd = this - return IO.monad().binding { + return MD.binding { getArticleBySlug(cmd.slug, cmd.user.some()).bind().fold( { ArticleFavoriteError.NotFound.left() }, { @@ -179,17 +185,18 @@ interface FavoriteUseCase { } } ) - }.fix() + } } } -interface UnfavoriteUseCase { - val getArticleBySlug: GetArticleBySlug - val removeFavorite: RemoveFavorite +interface UnfavoriteUseCase { + val getArticleBySlug: GetArticleBySlug + val removeFavorite: RemoveFavorite + val MD: MonadDefer - fun UnfavoriteArticleCommand.runUseCase(): IO> { + fun UnfavoriteArticleCommand.runUseCase(): Kind> { val cmd = this - return IO.monad().binding { + return MD.binding { getArticleBySlug(cmd.slug, cmd.user.some()).bind().fold( { ArticleUnfavoriteError.NotFound.left() }, { @@ -203,33 +210,35 @@ interface UnfavoriteUseCase { } } ) - }.fix() + } } } -interface CommentUseCase { - val getArticleBySlug: GetArticleBySlug - val addComment: AddComment +interface CommentUseCase { + val getArticleBySlug: GetArticleBySlug + val addComment: AddComment + val MD: MonadDefer - fun CommentArticleCommand.runUsecase(): IO> { + fun CommentArticleCommand.runUsecase(): Kind> { val cmd = this - return IO.monad().binding { + return MD.binding { getArticleBySlug(cmd.slug, cmd.user.some()).bind().fold( { ArticleCommentError.NotFound.left() }, { addComment(it.id, cmd.comment, cmd.user).bind().right() } ) - }.fix() + } } } -interface DeleteCommentUseCase { - val getArticleBySlug: GetArticleBySlug - val getComment: GetComment - val deleteComment: DeleteComment +interface DeleteCommentUseCase { + val getArticleBySlug: GetArticleBySlug + val getComment: GetComment + val deleteComment: DeleteComment + val MD: MonadDefer - fun DeleteCommentCommand.runUseCase(): IO> { + fun DeleteCommentCommand.runUseCase(): Kind> { val cmd = this - return IO.monad().binding { + return MD.binding { getArticleBySlug(cmd.slug, cmd.user.some()).bind().fold( { ArticleCommentDeleteError.ArticleNotFound.left() }, { @@ -245,29 +254,30 @@ interface DeleteCommentUseCase { ) } ) - }.fix() + } } } -interface GetCommentsUseCase { - val getArticleBySlug: GetArticleBySlug - val getComments: GetComments +interface GetCommentsUseCase { + val getArticleBySlug: GetArticleBySlug + val getComments: GetComments + val MD: MonadDefer - fun GetCommentsCommand.runUseCase(): IO>> { + fun GetCommentsCommand.runUseCase(): Kind>> { val cmd = this - return IO.monad().binding { + return MD.binding { getArticleBySlug(cmd.slug, cmd.user).bind().fold( { none>() }, { getComments(it.id, cmd.user).bind().some() } ) - }.fix() + } } } -interface GetTagsUseCase { - val getTags: GetTags +interface GetTagsUseCase { + val getTags: GetTags - fun GetTagsCommand.runUseCase(): IO> = getTags() + fun GetTagsCommand.runUseCase(): Kind> = getTags() } private fun Option
.getOrSystemError(slug: String) = this.getOrElse { diff --git a/realworld-domain/src/main/kotlin/io/realworld/domain/profiles/Dependencies.kt b/realworld-domain/src/main/kotlin/io/realworld/domain/profiles/Dependencies.kt index 09596ad..6a89015 100644 --- a/realworld-domain/src/main/kotlin/io/realworld/domain/profiles/Dependencies.kt +++ b/realworld-domain/src/main/kotlin/io/realworld/domain/profiles/Dependencies.kt @@ -1,11 +1,11 @@ package io.realworld.domain.profiles +import arrow.Kind import arrow.core.Option -import arrow.effects.IO import io.realworld.domain.users.User import io.realworld.domain.users.UserId -typealias GetUserByUsername = (username: String) -> IO> -typealias HasFollower = (followee: UserId, follower: UserId) -> IO -typealias AddFollower = (followee: UserId, follower: UserId) -> IO -typealias RemoveFollower = (followee: UserId, follower: UserId) -> IO +typealias GetUserByUsername = (username: String) -> Kind> +typealias HasFollower = (followee: UserId, follower: UserId) -> Kind +typealias AddFollower = (followee: UserId, follower: UserId) -> Kind +typealias RemoveFollower = (followee: UserId, follower: UserId) -> Kind diff --git a/realworld-domain/src/main/kotlin/io/realworld/domain/profiles/UseCases.kt b/realworld-domain/src/main/kotlin/io/realworld/domain/profiles/UseCases.kt index a8869e9..14ed744 100644 --- a/realworld-domain/src/main/kotlin/io/realworld/domain/profiles/UseCases.kt +++ b/realworld-domain/src/main/kotlin/io/realworld/domain/profiles/UseCases.kt @@ -1,12 +1,11 @@ package io.realworld.domain.profiles +import arrow.Kind import arrow.core.Option import arrow.core.none import arrow.core.some import arrow.core.toOption -import arrow.effects.IO -import arrow.effects.fix -import arrow.effects.instances.io.monad.monad +import arrow.typeclasses.Monad import arrow.typeclasses.binding import io.realworld.domain.users.User @@ -14,13 +13,14 @@ data class GetProfileCommand(val username: String, val current: Option) data class FollowCommand(val username: String, val current: User) data class UnfollowCommand(val username: String, val current: User) -interface GetProfileUseCase { - val getUser: GetUserByUsername - val hasFollower: HasFollower +interface GetProfileUseCase { + val getUser: GetUserByUsername + val hasFollower: HasFollower + val M: Monad - fun GetProfileCommand.runUseCase(): IO> { + fun GetProfileCommand.runUseCase(): Kind> { val cmd = this - return IO.monad().binding { + return M.binding { getUser(cmd.username).bind().fold( { none() }, { @@ -35,17 +35,18 @@ interface GetProfileUseCase { ).some() } ) - }.fix() + } } } -interface FollowUseCase { - val getUser: GetUserByUsername - val addFollower: AddFollower +interface FollowUseCase { + val getUser: GetUserByUsername + val addFollower: AddFollower + val M: Monad - fun FollowCommand.runUseCase(): IO> { + fun FollowCommand.runUseCase(): Kind> { val cmd = this - return IO.monad().binding { + return M.binding { getUser(cmd.username).bind().fold( { none() }, { @@ -58,17 +59,18 @@ interface FollowUseCase { ).some() } ) - }.fix() + } } } -interface UnfollowUseCase { - val getUser: GetUserByUsername - val removeFollower: RemoveFollower +interface UnfollowUseCase { + val getUser: GetUserByUsername + val removeFollower: RemoveFollower + val M: Monad - fun UnfollowCommand.runUseCase(): IO> { + fun UnfollowCommand.runUseCase(): Kind> { val cmd = this - return IO.monad().binding { + return M.binding { getUser(cmd.username).bind().fold( { none() }, { @@ -81,6 +83,6 @@ interface UnfollowUseCase { ).some() } ) - }.fix() + } } } diff --git a/realworld-domain/src/main/kotlin/io/realworld/domain/users/Dependencies.kt b/realworld-domain/src/main/kotlin/io/realworld/domain/users/Dependencies.kt index 30f9d1d..527905e 100644 --- a/realworld-domain/src/main/kotlin/io/realworld/domain/users/Dependencies.kt +++ b/realworld-domain/src/main/kotlin/io/realworld/domain/users/Dependencies.kt @@ -1,16 +1,18 @@ package io.realworld.domain.users +import arrow.Kind import arrow.core.Either import arrow.core.Option -import arrow.effects.IO -typealias ValidateUserRegistration = (reg: UserRegistration) -> IO> -typealias CreateUser = (user: ValidUserRegistration) -> IO +typealias ValidateUserRegistration = + (reg: UserRegistration) -> Kind> +typealias CreateUser = (user: ValidUserRegistration) -> Kind -typealias ValidateUserUpdate = (update: UserUpdate, current: User) -> IO> -typealias UpdateUser = (update: ValidUserUpdate, current: User) -> IO +typealias ValidateUserUpdate = + (update: UserUpdate, current: User) -> Kind> +typealias UpdateUser = (update: ValidUserUpdate, current: User) -> Kind -typealias GetUserByEmail = (email: String) -> IO> +typealias GetUserByEmail = (email: String) -> Kind> -typealias ExistsByEmail = (email: String) -> IO -typealias ExistsByUsername = (username: String) -> IO +typealias ExistsByEmail = (email: String) -> Kind +typealias ExistsByUsername = (username: String) -> Kind diff --git a/realworld-domain/src/main/kotlin/io/realworld/domain/users/Services.kt b/realworld-domain/src/main/kotlin/io/realworld/domain/users/Services.kt index 5b2b585..28aa351 100644 --- a/realworld-domain/src/main/kotlin/io/realworld/domain/users/Services.kt +++ b/realworld-domain/src/main/kotlin/io/realworld/domain/users/Services.kt @@ -1,25 +1,25 @@ package io.realworld.domain.users +import arrow.Kind import arrow.core.Either import arrow.core.getOrElse import arrow.core.left import arrow.core.right -import arrow.effects.IO -import arrow.effects.fix -import arrow.effects.instances.io.monad.monad +import arrow.typeclasses.Monad import arrow.typeclasses.binding import io.realworld.domain.common.Auth import io.realworld.domain.common.Token import java.util.UUID -interface ValidateUserService { +interface ValidateUserService { val auth: Auth - val existsByUsername: ExistsByUsername - val existsByEmail: ExistsByEmail + val existsByUsername: ExistsByUsername + val existsByEmail: ExistsByEmail + val M: Monad - fun UserRegistration.validate(): IO> { + fun UserRegistration.validate(): Kind> { val cmd = this - return IO.monad().binding { + return M.binding { when { existsByEmail(cmd.email).bind() -> UserRegistrationError.EmailAlreadyTaken.left() @@ -36,18 +36,19 @@ interface ValidateUserService { ).right() } } - }.fix() + } } } -interface ValidateUserUpdateService { +interface ValidateUserUpdateService { val auth: Auth - val existsByUsername: ExistsByUsername - val existsByEmail: ExistsByEmail + val existsByUsername: ExistsByUsername + val existsByEmail: ExistsByEmail + val M: Monad - fun UserUpdate.validate(current: User): IO> { + fun UserUpdate.validate(current: User): Kind> { val cmd = this - return IO.monad().binding { + return M.binding { when { cmd.email.fold({ false }, { current.email !== it && existsByEmail(it).bind() }) -> UserUpdateError.EmailAlreadyTaken.left() @@ -61,6 +62,6 @@ interface ValidateUserUpdateService { image = image.getOrElse { current.image } ).right() } - }.fix() + } } } diff --git a/realworld-domain/src/main/kotlin/io/realworld/domain/users/UseCases.kt b/realworld-domain/src/main/kotlin/io/realworld/domain/users/UseCases.kt index 64ae8d3..cab8205 100644 --- a/realworld-domain/src/main/kotlin/io/realworld/domain/users/UseCases.kt +++ b/realworld-domain/src/main/kotlin/io/realworld/domain/users/UseCases.kt @@ -1,15 +1,13 @@ package io.realworld.domain.users +import arrow.Kind import arrow.core.Either import arrow.core.left import arrow.core.right import arrow.data.EitherT import arrow.data.value -import arrow.effects.ForIO -import arrow.effects.IO -import arrow.effects.fix -import arrow.effects.instances.io.monad.monad import arrow.instances.monad +import arrow.typeclasses.Monad import arrow.typeclasses.binding import io.realworld.domain.common.Auth @@ -30,53 +28,60 @@ sealed class UserUpdateError { object UsernameAlreadyTaken : UserUpdateError() } -interface RegisterUserUseCase { +interface RegisterUserUseCase { val auth: Auth - val createUser: CreateUser - val validateUser: ValidateUserRegistration + val createUser: CreateUser + val validateUser: ValidateUserRegistration + val M: Monad - fun RegisterUserCommand.runUseCase(): IO> { + fun RegisterUserCommand.runUseCase(): Kind> { val cmd = this - return EitherT.monad(IO.monad()).binding { + return EitherT.monad(M).binding { val validRegistration = EitherT(validateUser(cmd.data)).bind() - EitherT(createUser(validRegistration).map { it.right() }).bind() - }.value().fix() + EitherT( + M.run { createUser(validRegistration).map { it.right() } } + ).bind() + }.value() } } -interface LoginUserUseCase { +interface LoginUserUseCase { val auth: Auth - val getUser: GetUserByEmail + val getUser: GetUserByEmail + val M: Monad - fun LoginUserCommand.runUseCase(): IO> { + fun LoginUserCommand.runUseCase(): Kind> { val cmd = this - return EitherT.monad(IO.monad()).binding { + return EitherT.monad(M).binding { val userAndPassword = EitherT( - getUser(cmd.email).map { - it.toEither { UserLoginError.BadCredentials } + M.run { + getUser(cmd.email).map { it.toEither { UserLoginError.BadCredentials } } } ).bind() - EitherT(IO.just( + EitherT(M.just( when (auth.checkPassword(cmd.password, userAndPassword.encryptedPassword)) { true -> userAndPassword.right() false -> UserLoginError.BadCredentials.left() } )).bind() userAndPassword.user - }.value().fix() + }.value() } } -interface UpdateUserUseCase { +interface UpdateUserUseCase { val auth: Auth - val validateUpdate: ValidateUserUpdate - val updateUser: UpdateUser + val validateUpdate: ValidateUserUpdate + val updateUser: UpdateUser + val M: Monad - fun UpdateUserCommand.runUseCase(): IO> { + fun UpdateUserCommand.runUseCase(): Kind> { val cmd = this - return EitherT.monad(IO.monad()).binding { + return EitherT.monad(M).binding { val validUpdate = EitherT(validateUpdate(cmd.data, cmd.current)).bind() - EitherT(updateUser(validUpdate, current).map { it.right() }).bind() - }.value().fix() + EitherT( + M.run { updateUser(validUpdate, current).map { it.right() } } + ).bind() + }.value() } } diff --git a/realworld-domain/src/test/kotlin/io/realworld/domain/users/UsersTest.kt b/realworld-domain/src/test/kotlin/io/realworld/domain/users/UsersTest.kt index cfe7fbd..0e7a155 100644 --- a/realworld-domain/src/test/kotlin/io/realworld/domain/users/UsersTest.kt +++ b/realworld-domain/src/test/kotlin/io/realworld/domain/users/UsersTest.kt @@ -2,7 +2,10 @@ package io.realworld.domain.users import arrow.core.right +import arrow.effects.ForIO import arrow.effects.IO +import arrow.effects.fix +import arrow.effects.instances.io.monad.monad import arrow.effects.liftIO import io.realworld.domain.common.Auth import io.realworld.domain.common.Settings @@ -22,16 +25,17 @@ class RegisterUserWorkflowTests { "foo", "foo@bar.com", "bar" ) - val createUser0: CreateUser = { x -> + val createUser0: CreateUser = { x -> User(id = UUID.randomUUID().userId(), email = x.email, token = x.token, username = x.username).liftIO() } @Test fun `happy path`() { - val actual = object : RegisterUserUseCase { + val actual = object : RegisterUserUseCase { override val auth = auth0 override val createUser = createUser0 override val validateUser = { x: UserRegistration -> x.autovalid().right().liftIO() } + override val M = IO.monad() }.test(userRegistration).unsafeRunSync() assertThat(actual.isRight()).isTrue() @@ -40,18 +44,20 @@ class RegisterUserWorkflowTests { @Test fun `exceptions from dependencies are propagated`() { assertThatThrownBy { - object : RegisterUserUseCase { + object : RegisterUserUseCase { override val auth = auth0 - override val createUser: CreateUser = { _ -> IO.raiseError(RuntimeException("BOOM!")) } + override val createUser: CreateUser = { IO.raiseError(RuntimeException("BOOM!")) } override val validateUser = { x: UserRegistration -> x.autovalid().right().liftIO() } + override val M = IO.monad() }.test(userRegistration).unsafeRunSync() }.hasMessage("BOOM!") assertThatThrownBy { - object : RegisterUserUseCase { + object : RegisterUserUseCase { override val auth = auth0 override val createUser = createUser0 - override val validateUser: ValidateUserRegistration = { _ -> IO.raiseError(RuntimeException("BOOM!")) } + override val validateUser: ValidateUserRegistration = { IO.raiseError(RuntimeException("BOOM!")) } + override val M = IO.monad() }.test(userRegistration).unsafeRunSync() }.hasMessage("BOOM!") } @@ -61,22 +67,23 @@ class RegisterUserWorkflowTests { var userSaved = false catchThrowable { - object : RegisterUserUseCase { + object : RegisterUserUseCase { override val auth = auth0 - override val createUser: CreateUser = { x -> + override val createUser: CreateUser = { x -> IO { userSaved = true User(id = UUID.randomUUID().userId(), email = x.email, token = x.token, username = x.username) } } - override val validateUser = { _: UserRegistration -> IO { throw RuntimeException("BOOM!") } } + override val validateUser: ValidateUserRegistration = { IO.raiseError(RuntimeException("BOOM!")) } + override val M = IO.monad() }.test(userRegistration).unsafeRunSync() } assertThat(userSaved).isFalse() } - private fun RegisterUserUseCase.test(input: UserRegistration) = this.run { - RegisterUserCommand(input).runUseCase() + private fun RegisterUserUseCase.test(input: UserRegistration) = this.run { + RegisterUserCommand(input).runUseCase().fix() } private fun UserRegistration.autovalid() = UUID.randomUUID().userId().let { diff --git a/realworld-infra/persistence/src/main/kotlin/io/realworld/persistence/ArticleRepository.kt b/realworld-infra/persistence/src/main/kotlin/io/realworld/persistence/ArticleRepository.kt index 31634c3..d1bd9f5 100644 --- a/realworld-infra/persistence/src/main/kotlin/io/realworld/persistence/ArticleRepository.kt +++ b/realworld-infra/persistence/src/main/kotlin/io/realworld/persistence/ArticleRepository.kt @@ -1,10 +1,11 @@ package io.realworld.persistence +import arrow.Kind import arrow.core.Option import arrow.core.getOrElse import arrow.core.some import arrow.core.toOption -import arrow.effects.IO +import arrow.effects.typeclasses.MonadDefer import io.realworld.domain.articles.Article import io.realworld.domain.articles.ArticleFilter import io.realworld.domain.articles.ArticleId @@ -106,12 +107,13 @@ private fun Comment.Companion.from(row: CommentRow, deps: CommentDeps) = Comment author = deps.author ) -class ArticleRepository( +class ArticleRepository( val jdbcTemplate: NamedParameterJdbcTemplate, - val userRepo: UserRepository -) { + val userRepo: UserRepository, + MD: MonadDefer +) : MonadDefer by MD { - fun create(article: ValidArticleCreation, user: User): IO
= IO { + fun create(article: ValidArticleCreation, user: User): Kind = defer { val row = insertArticleRow(article, user) val deps = ArticleDeps() @@ -128,105 +130,107 @@ class ArticleRepository( deps.tagList.addAll(article.tagList) } - Article.from(row, deps) + Article.from(row, deps).just() } - fun existsBySlug(slug: String): IO = ArticleTbl.let { - jdbcTemplate.queryIfExists(it.table, "${it.slug.eq()}", mapOf(it.slug to slug)) + fun existsBySlug(slug: String): Kind = defer { + ArticleTbl.let { + jdbcTemplate.queryIfExists(it.table, "${it.slug.eq()}", mapOf(it.slug to slug)).just() + } } - fun getBySlug(slug: String, user: Option): IO> = IO { - fetchRowBySlug(slug).map { Article.from(it, loadArticleDeps(it, user)) } + fun getBySlug(slug: String, user: Option): Kind> = defer { + fetchRowBySlug(slug).map { Article.from(it, loadArticleDeps(it, user)) }.just() } - fun deleteArticle(articleId: ArticleId): IO = with(ArticleTbl) { + fun deleteArticle(articleId: ArticleId): Kind = with(ArticleTbl) { val sql = "DELETE FROM $table WHERE ${id.eq()}" val params = mapOf(id to articleId.value) - IO { - jdbcTemplate.update(sql, params) + defer { + jdbcTemplate.update(sql, params).just() } } - fun updateArticle(update: ValidArticleUpdate, user: User): IO
= IO { + fun updateArticle(update: ValidArticleUpdate, user: User): Kind = defer { val row = updateArticleRow(update) - Article.from(row, loadArticleDeps(row, user.some())) + Article.from(row, loadArticleDeps(row, user.some())).just() } - fun addFavorite(articleId: ArticleId, user: User): IO = with(ArticleFavoriteTbl) { + fun addFavorite(articleId: ArticleId, user: User): Kind = with(ArticleFavoriteTbl) { val sql = "${table.insert(article_id, user_id)} ON CONFLICT ($article_id, $user_id) DO NOTHING" val params = mapOf(article_id to articleId.value, user_id to user.id.value) - IO { - jdbcTemplate.update(sql, params) + defer { + jdbcTemplate.update(sql, params).just() } } - fun removeFavorite(articleId: ArticleId, user: User): IO = with(ArticleFavoriteTbl) { + fun removeFavorite(articleId: ArticleId, user: User): Kind = with(ArticleFavoriteTbl) { val sql = "DELETE FROM $table WHERE ${article_id.eq()} AND ${user_id.eq()}" val params = mapOf(article_id to articleId.value, user_id to user.id.value) - IO { - jdbcTemplate.update(sql, params) + defer { + jdbcTemplate.update(sql, params).just() } } - fun getComment(commentId: Long, user: User): IO> = IO { + fun getComment(commentId: Long, user: User): Kind> = defer { fetchCommentRowById(commentId).map { Comment.from(it, CommentDeps(fetchAuthor(it.authorId, user.some()))) - } + }.just() } - fun addComment(articleId: ArticleId, comment: String, user: User): IO = IO { + fun addComment(articleId: ArticleId, comment: String, user: User): Kind = defer { val row = insertCommentRow(articleId, comment, user) val deps = CommentDeps(fetchAuthor(row.authorId, user.some())) - Comment.from(row, deps) + Comment.from(row, deps).just() } - fun deleteComment(commentId: Long): IO = with(ArticleCommentTbl) { + fun deleteComment(commentId: Long): Kind = with(ArticleCommentTbl) { val sql = "DELETE FROM $table WHERE ${id.eq()}" val params = mapOf(id to commentId) - IO { - jdbcTemplate.update(sql, params) + defer { + jdbcTemplate.update(sql, params).just() } } - fun getComments(articleId: ArticleId, user: Option): IO> = with(ArticleCommentTbl) { + fun getComments(articleId: ArticleId, user: Option): Kind> = with(ArticleCommentTbl) { val sql = "SELECT * from $table WHERE ${article_id.eq()}" val params = mapOf(article_id to articleId.value) - IO { + defer { jdbcTemplate.query(sql, params) { rs, _ -> CommentRow.fromRs(rs) }.map { Comment.from(it, CommentDeps(fetchAuthor(it.authorId, user))) - } + }.just() } } - fun getArticles(filter: ArticleFilter, user: Option): IO> = IO { + fun getArticles(filter: ArticleFilter, user: Option): Kind> = defer { val rows = fetchArticleRows(filter.toQueryParts(), filter.limit, filter.offset) // NOTE: opt for simplicity (query limit defaults to 20), thus let's loop - rows.map { row -> Article.from(row, loadArticleDeps(row, user)) } + rows.map { row -> Article.from(row, loadArticleDeps(row, user)) }.just() } - fun getArticlesCount(filter: ArticleFilter): IO = IO { - fetchArticleRowCount(filter.toQueryParts()) + fun getArticlesCount(filter: ArticleFilter): Kind = defer { + fetchArticleRowCount(filter.toQueryParts()).just() } - fun getFeeds(filter: FeedFilter, user: User): IO> = IO { + fun getFeeds(filter: FeedFilter, user: User): Kind> = defer { val rows = fetchArticleRows(user.toFeedsQueryParts(), filter.limit, filter.offset) // NOTE: opt for simplicity (query limit defaults to 20), thus let's loop - rows.map { row -> Article.from(row, loadArticleDeps(row, user.some())) } + rows.map { row -> Article.from(row, loadArticleDeps(row, user.some())) }.just() } - fun getFeedsCount(user: User): IO = IO { - fetchArticleRowCount(user.toFeedsQueryParts()) + fun getFeedsCount(user: User): Kind = defer { + fetchArticleRowCount(user.toFeedsQueryParts()).just() } - fun getTags(): IO> = with(TagTbl) { - IO { - jdbcTemplate.query("SELECT $name FROM $table") { rs, _ -> rs.getString(name) }.toSet() + fun getTags(): Kind> = with(TagTbl) { + defer { + jdbcTemplate.query("SELECT $name FROM $table") { rs, _ -> rs.getString(name) }.toSet().just() } } private fun loadArticleDeps(row: ArticleRow, user: Option): ArticleDeps = ArticleDeps().apply { - favorited = user.map { isFavorited(row.id, it).unsafeRunSync() }.getOrElse { false } + favorited = user.map { isFavorited(row.id, it) }.getOrElse { false } favoritesCount = fetchFavoritesCount(row.id) tagList.addAll(fetchArticleTags(row.id)) author = fetchAuthor(row.authorId, user) @@ -373,13 +377,13 @@ class ArticleRepository( } private fun fetchAuthor(id: UserId, querier: Option): Profile = - userRepo.findById(id).unsafeRunSync().map { + userRepo.findByIdImpure(id).map { it.user.let { author -> Profile( username = author.username, bio = author.bio.toOption(), image = author.image.toOption(), - following = querier.map { userRepo.hasFollower(author.id, it.id).unsafeRunSync() } + following = querier.map { userRepo.hasFollowerImpure(author.id, it.id) } ) } }.getOrElse { throw RuntimeException("Corrupt DB: article author $id not found") } @@ -390,7 +394,7 @@ class ArticleRepository( jdbcTemplate.queryForObject(sql, params, { rs, _ -> rs.getLong("count") })!! } - private fun isFavorited(articleId: ArticleId, user: User): IO = with(ArticleFavoriteTbl) { + private fun isFavorited(articleId: ArticleId, user: User): Boolean = with(ArticleFavoriteTbl) { jdbcTemplate.queryIfExists( table, "${article_id.eq()} AND ${user_id.eq()}", diff --git a/realworld-infra/persistence/src/main/kotlin/io/realworld/persistence/QueryUtils.kt b/realworld-infra/persistence/src/main/kotlin/io/realworld/persistence/QueryUtils.kt index 4390dee..c52187f 100644 --- a/realworld-infra/persistence/src/main/kotlin/io/realworld/persistence/QueryUtils.kt +++ b/realworld-infra/persistence/src/main/kotlin/io/realworld/persistence/QueryUtils.kt @@ -1,6 +1,5 @@ package io.realworld.persistence -import arrow.effects.IO import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate object Dsl { @@ -14,10 +13,7 @@ internal fun NamedParameterJdbcTemplate.queryIfExists( table: String, where: String, params: Map -): IO = IO { - this.queryForObject( - "SELECT COUNT(*) FROM $table WHERE $where", - params, - { rs, _ -> rs.getInt("count") > 0 } - )!! -} +): Boolean = this.queryForObject( + "SELECT COUNT(*) FROM $table WHERE $where", + params +) { rs, _ -> rs.getInt("count") > 0 }!! diff --git a/realworld-infra/persistence/src/main/kotlin/io/realworld/persistence/UserRepository.kt b/realworld-infra/persistence/src/main/kotlin/io/realworld/persistence/UserRepository.kt index cd440b5..6f6f688 100644 --- a/realworld-infra/persistence/src/main/kotlin/io/realworld/persistence/UserRepository.kt +++ b/realworld-infra/persistence/src/main/kotlin/io/realworld/persistence/UserRepository.kt @@ -1,8 +1,9 @@ package io.realworld.persistence +import arrow.Kind import arrow.core.Option import arrow.core.toOption -import arrow.effects.IO +import arrow.effects.typeclasses.MonadDefer import io.realworld.domain.users.User import io.realworld.domain.users.UserAndPassword import io.realworld.domain.users.UserId @@ -17,9 +18,12 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate import java.sql.ResultSet import java.util.UUID -open class UserRepository(val jdbcTemplate: NamedParameterJdbcTemplate) { +open class UserRepository( + private val jdbcTemplate: NamedParameterJdbcTemplate, + val MD: MonadDefer +) : MonadDefer by MD { - fun User.Companion.fromRs(rs: ResultSet) = with(UserTbl) { + private fun User.Companion.fromRs(rs: ResultSet) = with(UserTbl) { User( id = UUID.fromString(rs.getString(id)).userId(), email = rs.getString(email), @@ -30,11 +34,11 @@ open class UserRepository(val jdbcTemplate: NamedParameterJdbcTemplate) { ) } - fun UserAndPassword.Companion.fromRs(rs: ResultSet) = with(UserTbl) { + private fun UserAndPassword.Companion.fromRs(rs: ResultSet) = with(UserTbl) { UserAndPassword(User.fromRs(rs), rs.getString(password)) } - fun create(user: ValidUserRegistration): IO { + fun create(user: ValidUserRegistration): Kind { val sql = with(UserTbl) { """ INSERT INTO $table ( @@ -54,12 +58,12 @@ open class UserRepository(val jdbcTemplate: NamedParameterJdbcTemplate) { password to user.encryptedPassword ) } - return IO { - jdbcTemplate.queryForObject(sql, params, { rs, _ -> User.fromRs(rs) })!! + return defer { + jdbcTemplate.queryForObject(sql, params) { rs, _ -> User.fromRs(rs) }!!.just() } } - fun update(update: ValidUserUpdate, current: User): IO { + fun update(update: ValidUserUpdate, current: User): Kind { val sql = with(UserTbl) { StringBuilder("UPDATE $table SET ${username.set()}, ${email.set()}, ${bio.set()}, ${image.set()}") .also { if (update.encryptedPassword.isDefined()) it.append(", ${password.set()}") } @@ -77,53 +81,61 @@ open class UserRepository(val jdbcTemplate: NamedParameterJdbcTemplate) { ) } - return IO { - jdbcTemplate.queryForObject(sql, params, { rs, _ -> User.fromRs(rs) })!! + return defer { + jdbcTemplate.queryForObject(sql, params) { rs, _ -> User.fromRs(rs) }!!.just() } } - fun findById(id: UserId): IO> = - IO { - DataAccessUtils.singleResult( - jdbcTemplate.query( - "SELECT * FROM ${UserTbl.table} WHERE ${UserTbl.id.eq()}", - mapOf(UserTbl.id to id.value), - { rs, _ -> UserAndPassword.fromRs(rs) } - ) - ).toOption() - } + fun findById(id: UserId): Kind> = + defer { findByIdImpure(id).just() } - fun findByEmail(email: String): IO> = - IO { + fun findByIdImpure(id: UserId): Option = + DataAccessUtils.singleResult( + jdbcTemplate.query( + "SELECT * FROM ${UserTbl.table} WHERE ${UserTbl.id.eq()}", + mapOf(UserTbl.id to id.value), + { rs, _ -> UserAndPassword.fromRs(rs) } + ) + ).toOption() + + fun findByEmail(email: String): Kind> = + defer { DataAccessUtils.singleResult( jdbcTemplate.query( "SELECT * FROM ${UserTbl.table} WHERE ${UserTbl.email.eq()}", - mapOf(UserTbl.email to email), - { rs, _ -> UserAndPassword.fromRs(rs) } - ) - ).toOption() + mapOf(UserTbl.email to email) + ) { rs, _ -> UserAndPassword.fromRs(rs) } + ).toOption().just() } - fun findByUsername(username: String): IO> = - IO { + fun findByUsername(username: String): Kind> = + defer { DataAccessUtils.singleResult( jdbcTemplate.query( "SELECT * FROM ${UserTbl.table} WHERE ${UserTbl.username.eq()}", mapOf(UserTbl.username to username), { rs, _ -> User.fromRs(rs) } ) - ).toOption() + ).toOption().just() } - open fun existsByEmail(email: String): IO = UserTbl.let { - jdbcTemplate.queryIfExists(it.table, "${it.email.eq()}", mapOf(it.email to email)) + open fun existsByEmail(email: String): Kind = defer { + UserTbl.let { + jdbcTemplate.queryIfExists(it.table, "${it.email.eq()}", mapOf(it.email to email)) + }.just() + } + + fun existsByUsername(username: String): Kind = defer { + UserTbl.let { + jdbcTemplate.queryIfExists(it.table, "${it.username.eq()}", mapOf(it.username to username)) + }.just() } - fun existsByUsername(username: String): IO = UserTbl.let { - jdbcTemplate.queryIfExists(it.table, "${it.username.eq()}", mapOf(it.username to username)) + fun hasFollower(followee: UserId, follower: UserId): Kind = defer { + hasFollowerImpure(followee, follower).just() } - fun hasFollower(followee: UserId, follower: UserId): IO = FollowTbl.let { + fun hasFollowerImpure(followee: UserId, follower: UserId): Boolean = FollowTbl.let { jdbcTemplate.queryIfExists( it.table, "${it.followee.eq()} AND ${it.follower.eq()}", @@ -131,19 +143,19 @@ open class UserRepository(val jdbcTemplate: NamedParameterJdbcTemplate) { ) } - fun addFollower(followee: UserId, follower: UserId): IO = FollowTbl.let { + fun addFollower(followee: UserId, follower: UserId): Kind = FollowTbl.let { val sql = "${it.table.insert(it.followee, it.follower)} ON CONFLICT (${it.followee}, ${it.follower}) DO NOTHING" val params = mapOf(it.followee to followee.value, it.follower to follower.value) - IO { - jdbcTemplate.update(sql, params) + defer { + jdbcTemplate.update(sql, params).just() } } - fun removeFollower(followee: UserId, follower: UserId): IO = FollowTbl.let { + fun removeFollower(followee: UserId, follower: UserId): Kind = FollowTbl.let { val sql = "DELETE FROM ${it.table} WHERE ${it.followee.eq()} AND ${it.follower.eq()}" val params = mapOf(it.followee to followee.value, it.follower to follower.value) - IO { - jdbcTemplate.update(sql, params) + defer { + jdbcTemplate.update(sql, params).just() } } }