WIP: Move API logic to shared services

This commit is contained in:
William Brawner 2023-09-24 20:07:31 -06:00
parent 2b66ea916b
commit a460421497
20 changed files with 235 additions and 169 deletions

View file

@ -7,7 +7,7 @@ plugins {
dependencies { dependencies {
implementation(kotlin("stdlib")) implementation(kotlin("stdlib"))
api(project(":core")) api(project(":core"))
implementation(project(":storage")) implementation(project(":service"))
api(libs.ktor.server.core) api(libs.ktor.server.core)
api(libs.ktor.serialization) api(libs.ktor.serialization)
api(libs.kotlinx.coroutines.core) api(libs.kotlinx.coroutines.core)

View file

@ -1,62 +0,0 @@
package com.wbrawner.twigs
import com.wbrawner.twigs.model.Budget
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.storage.BudgetRepository
import com.wbrawner.twigs.storage.PermissionRepository
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.response.*
import io.ktor.util.pipeline.*
suspend inline fun PipelineContext<Unit, ApplicationCall>.requireBudgetWithPermission(
permissionRepository: PermissionRepository,
userId: String,
budgetId: String?,
permission: Permission,
otherwise: () -> Unit
) {
if (budgetId.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "budgetId is required")
return
}
permissionRepository.findAll(
userId = userId,
budgetIds = listOf(budgetId)
).firstOrNull {
it.permission.isAtLeast(permission)
} ?: run {
errorResponse(HttpStatusCode.Forbidden, "Insufficient permissions on budget $budgetId")
otherwise()
}
}
suspend fun PipelineContext<Unit, ApplicationCall>.budgetWithPermission(
budgetRepository: BudgetRepository,
permissionRepository: PermissionRepository,
budgetId: String,
permission: Permission,
block: suspend (Budget) -> Unit
) {
val session = call.principal<Session>()!!
val userPermission = permissionRepository.findAll(
userId = session.userId,
budgetIds = listOf(budgetId)
).firstOrNull()
if (userPermission?.permission?.isAtLeast(permission) != true) {
errorResponse(HttpStatusCode.Forbidden)
return
}
block(budgetRepository.findAll(ids = listOf(budgetId)).first())
}
suspend inline fun PipelineContext<Unit, ApplicationCall>.errorResponse(
httpStatusCode: HttpStatusCode = HttpStatusCode.NotFound,
message: String? = null
) {
message?.let {
call.respond(httpStatusCode, ErrorResponse(message))
} ?: call.respond(httpStatusCode)
}

View file

@ -1,11 +1,8 @@
package com.wbrawner.twigs package com.wbrawner.twigs
import com.wbrawner.twigs.model.Budget
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.Session import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.model.UserPermission import com.wbrawner.twigs.service.budget.BudgetRequest
import com.wbrawner.twigs.storage.BudgetRepository import com.wbrawner.twigs.service.budget.BudgetService
import com.wbrawner.twigs.storage.PermissionRepository
import io.ktor.http.* import io.ktor.http.*
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.auth.* import io.ktor.server.auth.*
@ -13,113 +10,41 @@ import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
fun Application.budgetRoutes( fun Application.budgetRoutes(budgetService: BudgetService) {
budgetRepository: BudgetRepository,
permissionRepository: PermissionRepository
) {
routing { routing {
route("/api/budgets") { route("/api/budgets") {
authenticate(optional = false) { authenticate(optional = false) {
get { get {
val session = call.principal<Session>()!! val session = requireNotNull(call.principal<Session>()) { "session is required" }
val budgetIds = permissionRepository.findAll(userId = session.userId).map { it.budgetId } call.respond(budgetService.budgetsForUser(userId = session.userId))
if (budgetIds.isEmpty()) {
call.respond(emptyList<BudgetResponse>())
return@get
}
val budgets = budgetRepository.findAll(ids = budgetIds).map {
BudgetResponse(it, permissionRepository.findAll(budgetIds = listOf(it.id)))
}
call.respond(budgets)
} }
get("/{id}") { get("/{id}") {
budgetWithPermission( val session = requireNotNull(call.principal<Session>()) { "session is required" }
budgetRepository, val budgetId = requireNotNull(call.parameters["id"]) { "budgetId is required" }
permissionRepository, call.respond(budgetService.budget(budgetId = budgetId, userId = session.userId))
call.parameters["id"]!!,
Permission.READ
) { budget ->
val users = permissionRepository.findAll(budgetIds = listOf(budget.id))
call.respond(BudgetResponse(budget, users))
}
} }
post { post {
val session = call.principal<Session>()!! val session = call.principal<Session>()!!
val request = call.receive<BudgetRequest>() val request = call.receive<BudgetRequest>()
if (request.name.isNullOrBlank()) { call.respond(budgetService.save(request = request, userId = session.userId))
errorResponse(HttpStatusCode.BadRequest, "Name cannot be empty or null")
return@post
}
val budget = budgetRepository.save(
Budget(
name = request.name,
description = request.description
)
)
val users = request.users?.map {
permissionRepository.save(
UserPermission(
budgetId = budget.id,
userId = it.user,
permission = it.permission
)
)
}?.toMutableSet() ?: mutableSetOf()
if (users.none { it.userId == session.userId }) {
users.add(
permissionRepository.save(
UserPermission(
budgetId = budget.id,
userId = session.userId,
permission = Permission.OWNER
)
)
)
}
call.respond(BudgetResponse(budget, users))
} }
put("/{id}") { put("/{id}") {
budgetWithPermission( val session = requireNotNull(call.principal<Session>()) { "session was null" }
budgetRepository,
permissionRepository,
call.parameters["id"]!!,
Permission.MANAGE
) { budget ->
val request = call.receive<BudgetRequest>() val request = call.receive<BudgetRequest>()
val name = request.name ?: budget.name val budgetId = requireNotNull(call.parameters["id"]) { "budgetId is required" }
val description = request.description ?: budget.description call.respond(budgetService.save(request = request, userId = session.id, budgetId = budgetId))
val users = request.users?.map {
permissionRepository.save(UserPermission(budget.id, it.user, it.permission))
} ?: permissionRepository.findAll(budgetIds = listOf(budget.id))
permissionRepository.findAll(budgetIds = listOf(budget.id)).forEach {
if (it.permission != Permission.OWNER && users.none { userPermission -> userPermission.userId == it.userId }) {
permissionRepository.delete(it)
}
}
call.respond(
BudgetResponse(
budgetRepository.save(budget.copy(name = name, description = description)),
users
)
)
}
} }
delete("/{id}") { delete("/{id}") {
budgetWithPermission( val session = requireNotNull(call.principal<Session>()) { "session was null" }
budgetRepository, val budgetId = requireNotNull(call.parameters["id"]) { "budgetId is required" }
permissionRepository, budgetService.delete(budgetId = budgetId, userId = session.userId)
budgetId = call.parameters["id"]!!,
Permission.OWNER
) { budget ->
budgetRepository.delete(budget)
call.respond(HttpStatusCode.NoContent) call.respond(HttpStatusCode.NoContent)
} }
} }
} }
} }
} }
}

View file

@ -3,6 +3,10 @@ package com.wbrawner.twigs
import com.wbrawner.twigs.model.Category import com.wbrawner.twigs.model.Category
import com.wbrawner.twigs.model.Permission import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.Session import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.service.category.CategoryRequest
import com.wbrawner.twigs.service.category.CategoryResponse
import com.wbrawner.twigs.service.errorResponse
import com.wbrawner.twigs.service.requireBudgetWithPermission
import com.wbrawner.twigs.storage.CategoryRepository import com.wbrawner.twigs.storage.CategoryRepository
import com.wbrawner.twigs.storage.PermissionRepository import com.wbrawner.twigs.storage.PermissionRepository
import io.ktor.http.* import io.ktor.http.*

View file

@ -1,6 +0,0 @@
package com.wbrawner.twigs
import kotlinx.serialization.Serializable
@Serializable
data class ErrorResponse(val message: String)

View file

@ -3,6 +3,10 @@ package com.wbrawner.twigs
import com.wbrawner.twigs.model.Permission import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.RecurringTransaction import com.wbrawner.twigs.model.RecurringTransaction
import com.wbrawner.twigs.model.Session import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.service.errorResponse
import com.wbrawner.twigs.service.recurringtransaction.RecurringTransactionRequest
import com.wbrawner.twigs.service.recurringtransaction.asResponse
import com.wbrawner.twigs.service.requireBudgetWithPermission
import com.wbrawner.twigs.storage.PermissionRepository import com.wbrawner.twigs.storage.PermissionRepository
import com.wbrawner.twigs.storage.RecurringTransactionRepository import com.wbrawner.twigs.storage.RecurringTransactionRepository
import io.ktor.http.* import io.ktor.http.*

View file

@ -3,6 +3,10 @@ package com.wbrawner.twigs
import com.wbrawner.twigs.model.Permission import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.Session import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.model.Transaction import com.wbrawner.twigs.model.Transaction
import com.wbrawner.twigs.service.errorResponse
import com.wbrawner.twigs.service.requireBudgetWithPermission
import com.wbrawner.twigs.service.transaction.BalanceResponse
import com.wbrawner.twigs.service.transaction.TransactionRequest
import com.wbrawner.twigs.storage.PermissionRepository import com.wbrawner.twigs.storage.PermissionRepository
import com.wbrawner.twigs.storage.TransactionRepository import com.wbrawner.twigs.storage.TransactionRepository
import io.ktor.http.* import io.ktor.http.*

View file

@ -3,6 +3,11 @@ package com.wbrawner.twigs
import com.wbrawner.twigs.model.PasswordResetToken import com.wbrawner.twigs.model.PasswordResetToken
import com.wbrawner.twigs.model.Session import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.model.User import com.wbrawner.twigs.model.User
import com.wbrawner.twigs.service.errorResponse
import com.wbrawner.twigs.service.user.LoginRequest
import com.wbrawner.twigs.service.user.PasswordResetRequest
import com.wbrawner.twigs.service.user.ResetPasswordRequest
import com.wbrawner.twigs.service.user.UserRequest
import com.wbrawner.twigs.storage.* import com.wbrawner.twigs.storage.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.server.application.* import io.ktor.server.application.*

View file

@ -29,6 +29,7 @@ ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" } ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" } ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
ktor-server-html = { module = "io.ktor:ktor-server-html-builder", version.ref = "ktor" }
ktor-server-sessions = { module = "io.ktor:ktor-server-sessions", version.ref = "ktor" } ktor-server-sessions = { module = "io.ktor:ktor-server-sessions", version.ref = "ktor" }
ktor-server-test = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" } ktor-server-test = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }

19
service/build.gradle.kts Normal file
View file

@ -0,0 +1,19 @@
plugins {
`java-library`
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
dependencies {
implementation(kotlin("stdlib"))
api(libs.ktor.server.core)
implementation(project(":storage"))
api(libs.ktor.serialization)
api(libs.kotlinx.coroutines.core)
testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine)
}
tasks.getByName<Test>("test") {
useJUnitPlatform()
}

View file

@ -0,0 +1,21 @@
package com.wbrawner.twigs.service
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.service.budget.BudgetResponse
import com.wbrawner.twigs.storage.BudgetRepository
import com.wbrawner.twigs.storage.PermissionRepository
import io.ktor.http.*
suspend fun Pair<BudgetRepository, PermissionRepository>.budgetWithPermission(
userId: String,
budgetId: String,
permission: Permission
): BudgetResponse {
val allPermissions = second.findAll(budgetIds = listOf(budgetId))
val userPermission = allPermissions.firstOrNull { it.userId == userId }
?: throw HttpException(HttpStatusCode.NotFound)
if (!userPermission.permission.isAtLeast(permission)) {
throw HttpException(HttpStatusCode.Forbidden)
}
return BudgetResponse(first.findAll(ids = listOf(budgetId)).first(), allPermissions)
}

View file

@ -0,0 +1,18 @@
package com.wbrawner.twigs.service
import io.ktor.http.*
import kotlinx.serialization.Serializable
@Serializable
data class ErrorResponse(val message: String)
class HttpException(
val statusCode: HttpStatusCode,
override val cause: Throwable? = null,
override val message: String? = null
) : Throwable() {
constructor(statusCode: HttpStatusCode) : this(statusCode = statusCode, message = statusCode.description)
fun toResponse(): ErrorResponse =
ErrorResponse(requireNotNull(message) { "Cannot send error to client without message" })
}

View file

@ -1,9 +1,10 @@
package com.wbrawner.twigs package com.wbrawner.twigs.service.budget
import com.wbrawner.twigs.model.Budget import com.wbrawner.twigs.model.Budget
import com.wbrawner.twigs.model.UserPermission import com.wbrawner.twigs.model.UserPermission
import com.wbrawner.twigs.service.user.UserPermissionRequest
import com.wbrawner.twigs.service.user.UserPermissionResponse
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.*
@Serializable @Serializable
data class BudgetRequest( data class BudgetRequest(
@ -20,7 +21,7 @@ data class BudgetResponse(
val users: List<UserPermissionResponse> val users: List<UserPermissionResponse>
) { ) {
constructor(budget: Budget, users: Iterable<UserPermission>) : this( constructor(budget: Budget, users: Iterable<UserPermission>) : this(
Objects.requireNonNull<String>(budget.id), requireNotNull(budget.id),
budget.name, budget.name,
budget.description, budget.description,
users.map { userPermission: UserPermission -> users.map { userPermission: UserPermission ->

View file

@ -0,0 +1,130 @@
package com.wbrawner.twigs.service.budget
import com.wbrawner.twigs.model.Budget
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.UserPermission
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.service.budgetWithPermission
import com.wbrawner.twigs.service.user.UserPermissionRequest
import com.wbrawner.twigs.storage.BudgetRepository
import com.wbrawner.twigs.storage.PermissionRepository
import io.ktor.http.*
interface BudgetService {
suspend fun budgetsForUser(userId: String): List<BudgetResponse>
suspend fun budget(budgetId: String, userId: String): BudgetResponse
suspend fun save(request: BudgetRequest, userId: String, budgetId: String? = null): BudgetResponse
suspend fun delete(budgetId: String, userId: String)
}
class DefaultBudgetService(
private val budgetRepository: BudgetRepository,
private val permissionRepository: PermissionRepository
) : BudgetService {
private val budgetPermissionRepository = budgetRepository to permissionRepository
override suspend fun budgetsForUser(userId: String): List<BudgetResponse> {
val budgetIds = permissionRepository.findAll(userId = userId).map { it.budgetId }
if (budgetIds.isEmpty()) {
return emptyList()
}
return budgetRepository.findAll(ids = budgetIds).map {
BudgetResponse(it, permissionRepository.findAll(budgetIds = listOf(it.id)))
}
}
override suspend fun budget(budgetId: String, userId: String): BudgetResponse =
budgetPermissionRepository.budgetWithPermission(userId, budgetId, Permission.READ)
override suspend fun save(request: BudgetRequest, userId: String, budgetId: String?): BudgetResponse {
val budget = budgetId?.let {
budgetPermissionRepository.budgetWithPermission(
budgetId = it,
userId = userId,
permission = Permission.MANAGE
).run {
Budget(
id = it,
name = request.name ?: name,
description = request.description ?: description
)
}
} ?: run {
if (request.name.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "Name cannot be empty or null")
}
Budget(
name = request.name,
description = request.description
)
}
val users = budgetId?.let {
// If user is owner, apply changes
// If user is manager, make sure they're not changing ownership
val oldUsers = permissionRepository.findAll(budgetIds = listOf(budgetId))
val oldPermissions = oldUsers.associate { it.userId to it.permission }
val currentUserPermission = oldPermissions[userId] ?: throw HttpException(HttpStatusCode.NotFound)
val newUsers = request.users?.map { userPermission ->
if (userPermission.permission == Permission.OWNER
&& oldPermissions[userPermission.user] !== Permission.OWNER
&& currentUserPermission != Permission.OWNER
) {
// The user is attempting to add a new owner
throw HttpException(
HttpStatusCode.Forbidden,
message = "You must be an owner to be able to modify other users' ownership"
)
}
userPermission
} ?: return@let oldUsers.map { UserPermissionRequest(it.userId, it.permission) }
oldPermissions.filterValues { it == Permission.OWNER }
.forEach { (user, permission) ->
if (newUsers.none { it.user == user && it.permission == permission }
&& currentUserPermission != Permission.OWNER
) {
// The user is attempting to remove a previous owner
throw HttpException(
HttpStatusCode.Forbidden,
message = "You must be an owner to be able to modify other users' ownership"
)
}
}
oldUsers.forEach { oldUserPermission ->
if (newUsers.none { it.user == oldUserPermission.userId && it.permission == oldUserPermission.permission }) {
permissionRepository.delete(oldUserPermission)
}
}
newUsers
} ?: run {
val newUsers = request.users
?.toMutableList()
?: mutableListOf()
val currentUserPermission = newUsers.firstOrNull { it.user == userId }
if (currentUserPermission == null || currentUserPermission.permission != Permission.OWNER) {
newUsers.removeIf { it.user == userId }
newUsers.add(UserPermissionRequest(userId, Permission.OWNER))
}
newUsers
}
val savedBudget = budgetRepository.save(budget)
return BudgetResponse(
savedBudget,
users.map {
permissionRepository.save(
UserPermission(
budgetId = savedBudget.id,
userId = it.user,
permission = it.permission
)
)
}
)
}
override suspend fun delete(budgetId: String, userId: String) {
val budgetResponse = budgetPermissionRepository.budgetWithPermission(userId, budgetId, Permission.OWNER)
budgetRepository.delete(Budget(budgetResponse.id, budgetResponse.name, budgetResponse.description))
}
}

View file

@ -1,4 +1,4 @@
package com.wbrawner.twigs package com.wbrawner.twigs.service.category
import com.wbrawner.twigs.model.Category import com.wbrawner.twigs.model.Category
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View file

@ -1,4 +1,4 @@
package com.wbrawner.twigs package com.wbrawner.twigs.service.recurringtransaction
import com.wbrawner.twigs.model.RecurringTransaction import com.wbrawner.twigs.model.RecurringTransaction
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View file

@ -1,4 +1,4 @@
package com.wbrawner.twigs package com.wbrawner.twigs.service.transaction
import com.wbrawner.twigs.model.Transaction import com.wbrawner.twigs.model.Transaction
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View file

@ -1,4 +1,4 @@
package com.wbrawner.twigs package com.wbrawner.twigs.service.user
import com.wbrawner.twigs.model.PasswordResetToken import com.wbrawner.twigs.model.PasswordResetToken
import com.wbrawner.twigs.model.Permission import com.wbrawner.twigs.model.Permission

View file

@ -1,3 +1,4 @@
rootProject.name = "twigs" rootProject.name = "twigs"
include("core", "api", "app", "storage", "db", "web") include("core", "api", "app", "storage", "db", "web")
include("testhelpers") include("testhelpers")
include("service")

View file

@ -8,6 +8,7 @@ plugins {
dependencies { dependencies {
implementation(kotlin("stdlib")) implementation(kotlin("stdlib"))
api(libs.ktor.server.core) api(libs.ktor.server.core)
implementation(libs.ktor.server.html)
testImplementation(libs.junit.jupiter.api) testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine) testRuntimeOnly(libs.junit.jupiter.engine)
} }