Finish implementing remaining service classes

This commit is contained in:
William Brawner 2024-03-27 16:50:06 -06:00
parent a460421497
commit 9a01fb39ec
23 changed files with 931 additions and 661 deletions

View file

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

View file

@ -1,48 +1,58 @@
package com.wbrawner.twigs package com.wbrawner.twigs
import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.service.budget.BudgetRequest
import com.wbrawner.twigs.service.budget.BudgetService import com.wbrawner.twigs.service.budget.BudgetService
import com.wbrawner.twigs.service.requireSession
import com.wbrawner.twigs.service.respondCatching
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.*
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.util.*
fun Application.budgetRoutes(budgetService: BudgetService) { fun Application.budgetRoutes(budgetService: BudgetService) {
routing { routing {
route("/api/budgets") { route("/api/budgets") {
authenticate(optional = false) { authenticate(optional = false) {
get { get {
val session = requireNotNull(call.principal<Session>()) { "session is required" } call.respondCatching {
call.respond(budgetService.budgetsForUser(userId = session.userId)) budgetService.budgetsForUser(userId = requireSession().userId)
}
} }
get("/{id}") { get("/{id}") {
val session = requireNotNull(call.principal<Session>()) { "session is required" } call.respondCatching {
val budgetId = requireNotNull(call.parameters["id"]) { "budgetId is required" } budgetService.budget(
call.respond(budgetService.budget(budgetId = budgetId, userId = session.userId)) budgetId = call.parameters.getOrFail("id"),
userId = requireSession().userId
)
}
} }
post { post {
val session = call.principal<Session>()!! call.respondCatching {
val request = call.receive<BudgetRequest>() budgetService.save(request = call.receive(), userId = requireSession().userId)
call.respond(budgetService.save(request = request, userId = session.userId)) }
} }
put("/{id}") { put("/{id}") {
val session = requireNotNull(call.principal<Session>()) { "session was null" } call.respondCatching {
val request = call.receive<BudgetRequest>() budgetService.save(
val budgetId = requireNotNull(call.parameters["id"]) { "budgetId is required" } request = call.receive(),
call.respond(budgetService.save(request = request, userId = session.id, budgetId = budgetId)) userId = requireSession().userId,
budgetId = call.parameters.getOrFail("id")
)
}
} }
delete("/{id}") { delete("/{id}") {
val session = requireNotNull(call.principal<Session>()) { "session was null" } call.respondCatching {
val budgetId = requireNotNull(call.parameters["id"]) { "budgetId is required" } budgetService.delete(
budgetService.delete(budgetId = budgetId, userId = session.userId) budgetId = call.parameters.getOrFail("id"),
call.respond(HttpStatusCode.NoContent) userId = requireSession().userId
)
HttpStatusCode.NoContent
}
} }
} }
} }

View file

@ -1,143 +1,64 @@
package com.wbrawner.twigs package com.wbrawner.twigs
import com.wbrawner.twigs.model.Category
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.service.category.CategoryRequest import com.wbrawner.twigs.service.category.CategoryRequest
import com.wbrawner.twigs.service.category.CategoryResponse import com.wbrawner.twigs.service.category.CategoryService
import com.wbrawner.twigs.service.errorResponse import com.wbrawner.twigs.service.requireSession
import com.wbrawner.twigs.service.requireBudgetWithPermission import com.wbrawner.twigs.service.respondCatching
import com.wbrawner.twigs.storage.CategoryRepository
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.*
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.util.*
fun Application.categoryRoutes( fun Application.categoryRoutes(categoryService: CategoryService) {
categoryRepository: CategoryRepository,
permissionRepository: PermissionRepository
) {
routing { routing {
route("/api/categories") { route("/api/categories") {
authenticate(optional = false) { authenticate(optional = false) {
get { get {
val session = call.principal<Session>()!! call.respondCatching {
val budgetIds = permissionRepository.findAll( categoryService.categories(
budgetIds = call.request.queryParameters.getAll("budgetIds"), budgetIds = call.request.queryParameters.getAll("budgetIds").orEmpty(),
userId = session.userId userId = requireSession().userId,
).map { it.budgetId }
if (budgetIds.isEmpty()) {
call.respond(emptyList<CategoryResponse>())
return@get
}
call.respond(categoryRepository.findAll(
budgetIds = budgetIds,
expense = call.request.queryParameters["expense"]?.toBoolean(), expense = call.request.queryParameters["expense"]?.toBoolean(),
archived = call.request.queryParameters["archived"]?.toBoolean() archived = call.request.queryParameters["archived"]?.toBoolean()
).map { it.asResponse() }) )
}
} }
get("/{id}") { get("/{id}") {
val session = call.principal<Session>()!! call.respondCatching {
val budgetIds = permissionRepository.findAll(userId = session.userId).map { it.budgetId } categoryService.category(
if (budgetIds.isEmpty()) { categoryId = call.parameters.getOrFail("id"),
errorResponse() userId = requireSession().userId
return@get
}
categoryRepository.findAll(
ids = call.parameters.getAll("id"),
budgetIds = budgetIds
) )
.map { it.asResponse() } }
.firstOrNull()?.let {
call.respond(it)
} ?: errorResponse()
} }
post { post {
val session = call.principal<Session>()!! call.respondCatching {
val request = call.receive<CategoryRequest>() categoryService.save(call.receive<CategoryRequest>(), requireSession().userId)
if (request.title.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty")
return@post
} }
if (request.budgetId.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Budget ID cannot be null or empty")
return@post
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
request.budgetId,
Permission.WRITE
) {
return@post
}
call.respond(
categoryRepository.save(
Category(
title = request.title,
description = request.description,
amount = request.amount ?: 0L,
expense = request.expense ?: true,
budgetId = request.budgetId
)
).asResponse()
)
} }
put("/{id}") { put("/{id}") {
val session = call.principal<Session>()!! call.respondCatching {
val request = call.receive<CategoryRequest>() categoryService.save(
val category = categoryRepository.findAll(ids = call.parameters.getAll("id")) request = call.receive<CategoryRequest>(),
.firstOrNull() userId = requireSession().userId,
?: run { categoryId = call.parameters.getOrFail("id")
call.respond(HttpStatusCode.NotFound)
return@put
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
category.budgetId,
Permission.WRITE
) {
return@put
}
call.respond(
categoryRepository.save(
category.copy(
title = request.title ?: category.title,
description = request.description ?: category.description,
amount = request.amount ?: category.amount,
expense = request.expense ?: category.expense,
archived = request.archived ?: category.archived,
)
).asResponse()
) )
} }
}
delete("/{id}") { delete("/{id}") {
val session = call.principal<Session>()!! call.respondCatching {
val categoryId = call.parameters.entries().first().value categoryService.delete(
val category = categoryRepository.findAll(ids = categoryId) call.parameters.getOrFail("id"),
.firstOrNull() requireSession().userId
?: run { )
errorResponse(HttpStatusCode.NotFound) HttpStatusCode.NoContent
return@delete
} }
requireBudgetWithPermission(
permissionRepository,
session.userId,
category.budgetId,
Permission.WRITE
) {
return@delete
}
categoryRepository.delete(category)
call.respond(HttpStatusCode.NoContent)
} }
} }
} }

View file

@ -1,162 +1,59 @@
package com.wbrawner.twigs package com.wbrawner.twigs
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.RecurringTransaction
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.RecurringTransactionRequest
import com.wbrawner.twigs.service.recurringtransaction.asResponse import com.wbrawner.twigs.service.recurringtransaction.RecurringTransactionService
import com.wbrawner.twigs.service.requireBudgetWithPermission import com.wbrawner.twigs.service.requireSession
import com.wbrawner.twigs.storage.PermissionRepository import com.wbrawner.twigs.service.respondCatching
import com.wbrawner.twigs.storage.RecurringTransactionRepository
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.*
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.util.pipeline.* import io.ktor.server.util.*
import java.time.Instant
fun Application.recurringTransactionRoutes(
recurringTransactionRepository: RecurringTransactionRepository,
permissionRepository: PermissionRepository
) {
suspend fun PipelineContext<Unit, ApplicationCall>.recurringTransactionAfterPermissionCheck(
id: String?,
userId: String,
success: suspend (RecurringTransaction) -> Unit
) {
if (id.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "id is required")
return
}
val recurringTransaction = recurringTransactionRepository.findAll(ids = listOf(id)).firstOrNull()
?: run {
errorResponse()
return
}
requireBudgetWithPermission(
permissionRepository,
userId,
recurringTransaction.budgetId,
Permission.WRITE
) {
application.log.info("No permissions on budget ${recurringTransaction.budgetId}.")
return
}
success(recurringTransaction)
}
fun Application.recurringTransactionRoutes(recurringTransactionService: RecurringTransactionService) {
routing { routing {
route("/api/recurringtransactions") { route("/api/recurringtransactions") {
authenticate(optional = false) { authenticate(optional = false) {
get { get {
val session = call.principal<Session>()!! call.respondCatching {
val budgetId = call.request.queryParameters["budgetId"] recurringTransactionService.recurringTransactions(
requireBudgetWithPermission( budgetId = call.request.queryParameters.getOrFail("budgetId"),
permissionRepository, userId = requireSession().userId
session.userId,
budgetId,
Permission.WRITE
) {
return@get
}
call.respond(
recurringTransactionRepository.findAll(
budgetId = budgetId!!
).map { it.asResponse() }
) )
} }
}
get("/{id}") { get("/{id}") {
val session = call.principal<Session>()!! call.respondCatching {
recurringTransactionAfterPermissionCheck(call.parameters["id"]!!, session.userId) { recurringTransactionService.recurringTransaction(
call.respond(it.asResponse()) call.parameters.getOrFail("id"),
requireSession().userId
)
} }
} }
post { post {
val session = call.principal<Session>()!! call.respondCatching {
val request = call.receive<RecurringTransactionRequest>() recurringTransactionService.save(
if (request.title.isNullOrBlank()) { request = call.receive<RecurringTransactionRequest>(),
errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty") userId = requireSession().userId
return@post
}
if (request.budgetId.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Budget ID cannot be null or empty")
return@post
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
request.budgetId,
Permission.WRITE
) {
return@post
}
call.respond(
recurringTransactionRepository.save(
RecurringTransaction(
title = request.title,
description = request.description,
amount = request.amount ?: 0L,
expense = request.expense ?: true,
budgetId = request.budgetId,
categoryId = request.categoryId,
createdBy = session.userId,
start = request.start?.toInstant() ?: Instant.now(),
finish = request.finish?.toInstant(),
frequency = request.frequency.asFrequency()
)
).asResponse()
) )
} }
}
put("/{id}") { put("/{id}") {
val session = call.principal<Session>()!! recurringTransactionService.save(
val request = call.receive<RecurringTransactionRequest>() request = call.receive<RecurringTransactionRequest>(),
recurringTransactionAfterPermissionCheck( userId = requireSession().userId,
call.parameters["id"]!!, recurringTransactionId = call.parameters.getOrFail("id")
session.userId
) { recurringTransaction ->
if (request.budgetId != recurringTransaction.budgetId) {
requireBudgetWithPermission(
permissionRepository,
session.userId,
request.budgetId,
Permission.WRITE
) {
return@recurringTransactionAfterPermissionCheck
}
}
call.respond(
recurringTransactionRepository.save(
recurringTransaction.copy(
title = request.title ?: recurringTransaction.title,
description = request.description ?: recurringTransaction.description,
amount = request.amount ?: recurringTransaction.amount,
expense = request.expense ?: recurringTransaction.expense,
categoryId = request.categoryId ?: recurringTransaction.categoryId,
budgetId = request.budgetId ?: recurringTransaction.budgetId,
start = request.start?.toInstant() ?: recurringTransaction.start,
finish = request.finish?.toInstant() ?: recurringTransaction.finish,
frequency = request.frequency.asFrequency()
) )
).asResponse()
)
}
} }
delete("/{id}") { delete("/{id}") {
val session = call.principal<Session>()!! call.respondCatching {
recurringTransactionAfterPermissionCheck(call.parameters["id"]!!, session.userId) { recurringTransactionService.delete(call.parameters.getOrFail("id"), requireSession().userId)
val response = if (recurringTransactionRepository.delete(it)) {
HttpStatusCode.NoContent HttpStatusCode.NoContent
} else {
HttpStatusCode.InternalServerError
}
call.respond(response)
} }
} }
} }

View file

@ -1,173 +1,85 @@
package com.wbrawner.twigs package com.wbrawner.twigs
import com.wbrawner.twigs.model.Permission import com.wbrawner.twigs.service.requireSession
import com.wbrawner.twigs.model.Session import com.wbrawner.twigs.service.respondCatching
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.BalanceResponse
import com.wbrawner.twigs.service.transaction.TransactionRequest import com.wbrawner.twigs.service.transaction.TransactionRequest
import com.wbrawner.twigs.storage.PermissionRepository import com.wbrawner.twigs.service.transaction.TransactionService
import com.wbrawner.twigs.storage.TransactionRepository
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.*
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.util.*
import java.time.Instant import java.time.Instant
fun Application.transactionRoutes( fun Application.transactionRoutes(transactionService: TransactionService) {
transactionRepository: TransactionRepository,
permissionRepository: PermissionRepository
) {
routing { routing {
route("/api/transactions") { route("/api/transactions") {
authenticate(optional = false) { authenticate(optional = false) {
get { get {
val session = call.principal<Session>()!! call.respondCatching {
call.respond( transactionService.transactions(
transactionRepository.findAll( budgetIds = call.request.queryParameters.getAll("budgetIds").orEmpty(),
budgetIds = permissionRepository.findAll(
budgetIds = call.request.queryParameters.getAll("budgetIds"),
userId = session.userId
).map { it.budgetId },
categoryIds = call.request.queryParameters.getAll("categoryIds"), categoryIds = call.request.queryParameters.getAll("categoryIds"),
from = call.request.queryParameters["from"]?.let { Instant.parse(it) }, from = call.request.queryParameters["from"]?.let { Instant.parse(it) },
to = call.request.queryParameters["to"]?.let { Instant.parse(it) }, to = call.request.queryParameters["to"]?.let { Instant.parse(it) },
expense = call.request.queryParameters["expense"]?.toBoolean(), expense = call.request.queryParameters["expense"]?.toBoolean(),
).map { it.asResponse() }) userId = requireSession().userId
)
}
} }
get("/{id}") { get("/{id}") {
val session = call.principal<Session>()!! call.respondCatching {
val transaction = transactionRepository.findAll( transactionService.transaction(
ids = call.parameters.getAll("id"), transactionId = call.parameters.getOrFail("id"),
budgetIds = permissionRepository.findAll( userId = requireSession().userId
userId = session.userId
) )
.map { it.budgetId } }
)
.map { it.asResponse() }
.firstOrNull()
transaction?.let {
call.respond(it)
} ?: errorResponse()
} }
get("/sum") { get("/sum") {
val categoryId = call.request.queryParameters["categoryId"] call.respondCatching {
val budgetId = call.request.queryParameters["budgetId"] BalanceResponse(
val from = call.request.queryParameters["from"]?.toInstant() ?: firstOfMonth transactionService.sum(
val to = call.request.queryParameters["to"]?.toInstant() ?: endOfMonth budgetId = call.request.queryParameters["budgetId"],
val balance = if (!categoryId.isNullOrBlank()) { categoryId = call.request.queryParameters["categoryId"],
if (!budgetId.isNullOrBlank()) { from = call.request.queryParameters["from"]?.toInstant() ?: firstOfMonth,
errorResponse( to = call.request.queryParameters["to"]?.toInstant() ?: endOfMonth,
HttpStatusCode.BadRequest, userId = requireSession().userId,
"budgetId and categoryId cannot be provided together" )
) )
return@get
} }
transactionRepository.sumByCategory(categoryId, from, to)
} else if (!budgetId.isNullOrBlank()) {
transactionRepository.sumByBudget(budgetId, from, to)
} else {
errorResponse(HttpStatusCode.BadRequest, "budgetId or categoryId must be provided to sum")
return@get
}
call.respond(BalanceResponse(balance))
} }
post { post {
val session = call.principal<Session>()!! call.respondCatching {
val request = call.receive<TransactionRequest>() transactionService.save(
if (request.title.isNullOrBlank()) { request = call.receive<TransactionRequest>(),
errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty") userId = requireSession().userId
return@post
}
if (request.budgetId.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Budget ID cannot be null or empty")
return@post
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
request.budgetId,
Permission.WRITE
) {
return@post
}
call.respond(
transactionRepository.save(
Transaction(
title = request.title,
description = request.description,
amount = request.amount ?: 0L,
expense = request.expense ?: true,
budgetId = request.budgetId,
categoryId = request.categoryId,
createdBy = session.userId,
date = request.date?.let { Instant.parse(it) } ?: Instant.now()
)
).asResponse()
) )
} }
}
put("/{id}") { put("/{id}") {
val session = call.principal<Session>()!! call.respondCatching {
val request = call.receive<TransactionRequest>() transactionService.save(
val transaction = transactionRepository.findAll(ids = call.parameters.getAll("id")) request = call.receive<TransactionRequest>(),
.firstOrNull() userId = requireSession().userId,
?: run { transactionId = call.parameters.getOrFail("id")
errorResponse()
return@put
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
transaction.budgetId,
Permission.WRITE
) {
return@put
}
call.respond(
transactionRepository.save(
transaction.copy(
title = request.title ?: transaction.title,
description = request.description ?: transaction.description,
amount = request.amount ?: transaction.amount,
expense = request.expense ?: transaction.expense,
date = request.date?.let { Instant.parse(it) } ?: transaction.date,
categoryId = request.categoryId ?: transaction.categoryId,
budgetId = request.budgetId ?: transaction.budgetId,
createdBy = transaction.createdBy,
)
).asResponse()
) )
} }
}
delete("/{id}") { delete("/{id}") {
val session = call.principal<Session>()!! call.respondCatching {
val transaction = transactionRepository.findAll(ids = call.parameters.getAll("id")) transactionService.delete(
.firstOrNull() transactionId = call.parameters.getOrFail("id"),
?: run { userId = requireSession().userId
errorResponse() )
return@delete
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
transaction.budgetId,
Permission.WRITE
) {
return@delete
}
val response = if (transactionRepository.delete(transaction)) {
HttpStatusCode.NoContent HttpStatusCode.NoContent
} else {
HttpStatusCode.InternalServerError
} }
call.respond(response)
} }
} }
} }

View file

@ -1,195 +1,84 @@
package com.wbrawner.twigs package com.wbrawner.twigs
import com.wbrawner.twigs.model.PasswordResetToken import com.wbrawner.twigs.service.requireSession
import com.wbrawner.twigs.model.Session import com.wbrawner.twigs.service.respondCatching
import com.wbrawner.twigs.model.User import com.wbrawner.twigs.service.user.UserService
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 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.*
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import java.time.Instant import io.ktor.server.util.*
fun Application.userRoutes( fun Application.userRoutes(userService: UserService) {
emailService: EmailService,
passwordResetRepository: PasswordResetRepository,
permissionRepository: PermissionRepository,
sessionRepository: SessionRepository,
userRepository: UserRepository,
passwordHasher: PasswordHasher
) {
routing { routing {
route("/api/users") { route("/api/users") {
post("/login") { post("/login") {
val request = call.receive<LoginRequest>() call.respondCatching {
val user = userService.login(call.receive())
userRepository.findAll(
nameOrEmail = request.username,
password = passwordHasher.hash(request.password)
)
.firstOrNull()
?: userRepository.findAll(
nameOrEmail = request.username,
password = passwordHasher.hash(request.password)
)
.firstOrNull()
?: run {
errorResponse(HttpStatusCode.Unauthorized, "Invalid credentials")
return@post
} }
val session = sessionRepository.save(Session(userId = user.id))
call.respond(session.asResponse())
} }
post("/register") { post("/register") {
val request = call.receive<UserRequest>() call.respondCatching {
if (request.username.isNullOrBlank()) { userService.register(call.receive())
errorResponse(HttpStatusCode.BadRequest, "Username must not be null or blank")
return@post
}
if (request.password.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Password must not be null or blank")
return@post
}
val existingUser = userRepository.findAll(nameOrEmail = request.username).firstOrNull()
?: request.email?.let {
return@let if (it.isBlank()) {
null
} else {
userRepository.findAll(nameOrEmail = it).firstOrNull()
} }
} }
existingUser?.let {
errorResponse(HttpStatusCode.BadRequest, "Username or email already taken")
return@post route("/resetpassword") {
post {
call.respondCatching {
userService.requestPasswordResetEmail(call.receive())
HttpStatusCode.Accepted
}
}
put {
call.respondCatching {
userService.resetPassword(call.receive())
HttpStatusCode.NoContent
}
} }
call.respond(
userRepository.save(
User(
name = request.username,
password = passwordHasher.hash(request.password),
email = if (request.email.isNullOrBlank()) "" else request.email
)
).asResponse()
)
} }
authenticate(optional = false) { authenticate(optional = false) {
get { get {
val query = call.request.queryParameters["query"] call.respondCatching {
val budgetIds = call.request.queryParameters.getAll("budgetId") userService.users(
if (query != null) { query = call.request.queryParameters["query"],
if (query.isBlank()) { budgetIds = call.request.queryParameters.getAll("budgetId"),
errorResponse(HttpStatusCode.BadRequest, "query cannot be empty") requestingUserId = requireSession().userId
)
} }
call.respond(userRepository.findAll(nameLike = query).map { it.asResponse() })
return@get
} else if (budgetIds == null || budgetIds.all { it.isBlank() }) {
errorResponse(HttpStatusCode.BadRequest, "query or budgetId required but absent")
}
permissionRepository.findAll(budgetIds = budgetIds)
.mapNotNull {
userRepository.findAll(ids = listOf(it.userId))
.firstOrNull()
?.asResponse()
}.run { call.respond(this) }
} }
get("/{id}") { get("/{id}") {
userRepository.findAll(ids = call.parameters.getAll("id")) call.respondCatching {
.firstOrNull() userService.user(call.parameters.getOrFail("id"))
?.asResponse() }
?.let { call.respond(it) }
?: errorResponse(HttpStatusCode.NotFound)
} }
put("/{id}") { put("/{id}") {
val session = call.principal<Session>()!! call.respondCatching {
val request = call.receive<UserRequest>() userService.save(
// TODO: Add some kind of admin denotation to allow admins to edit other users request = call.receive(),
if (call.parameters["id"] != session.userId) { targetUserId = call.parameters.getOrFail("id"),
errorResponse(HttpStatusCode.Forbidden) requestingUserId = requireSession().userId
return@put
}
call.respond(
userRepository.save(
userRepository.findAll(ids = call.parameters.getAll("id"))
.first()
.run {
copy(
name = request.username ?: name,
password = request.password?.let { passwordHasher.hash(it) } ?: password,
email = request.email ?: email
) )
} }
).asResponse()
)
} }
delete("/{id}") { delete("/{id}") {
val session = call.principal<Session>()!! call.respondCatching {
// TODO: Add some kind of admin denotation to allow admins to delete other users userService.delete(
val user = userRepository.findAll(call.parameters.entries().first().value).firstOrNull() targetUserId = call.parameters.getOrFail("id"),
if (user == null) { requestingUserId = requireSession().userId
errorResponse() )
return@delete HttpStatusCode.NoContent
}
if (user.id != session.userId) {
errorResponse(HttpStatusCode.Forbidden)
return@delete
}
userRepository.delete(user)
call.respond(HttpStatusCode.NoContent)
} }
} }
} }
route("/api/resetpassword") {
post {
val request = call.receive<ResetPasswordRequest>()
userRepository.findAll(nameOrEmail = request.username)
.firstOrNull()
?.let {
val email = it.email
val passwordResetToken = passwordResetRepository.save(PasswordResetToken(userId = it.id))
emailService.sendPasswordResetEmail(passwordResetToken, email)
}
call.respond(HttpStatusCode.Accepted)
}
}
route("/api/passwordreset") {
post {
val request = call.receive<PasswordResetRequest>()
val passwordResetToken = passwordResetRepository.findAll(listOf(request.token))
.firstOrNull()
?: run {
errorResponse(HttpStatusCode.Unauthorized, "Invalid token")
return@post
}
if (passwordResetToken.expiration.isBefore(Instant.now())) {
errorResponse(HttpStatusCode.Unauthorized, "Token expired")
return@post
}
userRepository.findAll(listOf(passwordResetToken.userId))
.firstOrNull()
?.let {
userRepository.save(it.copy(password = passwordHasher.hash(request.password)))
passwordResetRepository.delete(passwordResetToken)
}
?: run {
errorResponse(HttpStatusCode.InternalServerError, "Invalid token")
return@post
}
call.respond(HttpStatusCode.NoContent)
}
} }
} }
} }

View file

@ -26,6 +26,7 @@ dependencies {
implementation(libs.bcrypt) implementation(libs.bcrypt)
implementation(libs.logback) implementation(libs.logback)
implementation(libs.mail) implementation(libs.mail)
implementation(project(mapOf("path" to ":service")))
testImplementation(project(":testhelpers")) testImplementation(project(":testhelpers"))
testImplementation(libs.ktor.client.content.negotiation) testImplementation(libs.ktor.client.content.negotiation)
testImplementation(libs.ktor.server.test) testImplementation(libs.ktor.server.test)

View file

@ -5,7 +5,17 @@ import ch.qos.logback.classic.Level
import com.wbrawner.twigs.* import com.wbrawner.twigs.*
import com.wbrawner.twigs.db.* import com.wbrawner.twigs.db.*
import com.wbrawner.twigs.model.Session import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.storage.* import com.wbrawner.twigs.service.budget.BudgetService
import com.wbrawner.twigs.service.budget.DefaultBudgetService
import com.wbrawner.twigs.service.category.CategoryService
import com.wbrawner.twigs.service.category.DefaultCategoryService
import com.wbrawner.twigs.service.recurringtransaction.DefaultRecurringTransactionService
import com.wbrawner.twigs.service.recurringtransaction.RecurringTransactionService
import com.wbrawner.twigs.service.transaction.DefaultTransactionService
import com.wbrawner.twigs.service.transaction.TransactionService
import com.wbrawner.twigs.service.user.DefaultUserService
import com.wbrawner.twigs.service.user.UserService
import com.wbrawner.twigs.storage.PasswordHasher
import com.wbrawner.twigs.web.webRoutes import com.wbrawner.twigs.web.webRoutes
import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource import com.zaxxer.hikari.HikariDataSource
@ -81,50 +91,29 @@ fun Application.module() {
metadata metadata
} }
} }
moduleWithDependencies( val budgetRepository = JdbcBudgetRepository(it)
emailService = SmtpEmailService( val categoryRepository = JdbcCategoryRepository(it)
val permissionRepository = JdbcPermissionRepository(it)
val passwordResetRepository = JdbcPasswordResetRepository(it)
val passwordHasher = PasswordHasher { password ->
String(BCrypt.withDefaults().hash(10, metadata.salt.toByteArray(), password.toByteArray()))
}
val recurringTransactionRepository = JdbcRecurringTransactionRepository(it)
val sessionRepository = JdbcSessionRepository(it)
val transactionRepository = JdbcTransactionRepository(it)
val userRepository = JdbcUserRepository(it)
val emailService = SmtpEmailService(
from = System.getenv("TWIGS_SMTP_FROM"), from = System.getenv("TWIGS_SMTP_FROM"),
host = System.getenv("TWIGS_SMTP_HOST"), host = System.getenv("TWIGS_SMTP_HOST"),
port = System.getenv("TWIGS_SMTP_PORT")?.toIntOrNull(), port = System.getenv("TWIGS_SMTP_PORT")?.toIntOrNull(),
username = System.getenv("TWIGS_SMTP_USER"), username = System.getenv("TWIGS_SMTP_USER"),
password = System.getenv("TWIGS_SMTP_PASS"), password = System.getenv("TWIGS_SMTP_PASS"),
),
metadataRepository = JdbcMetadataRepository(it),
budgetRepository = JdbcBudgetRepository(it),
categoryRepository = JdbcCategoryRepository(it),
passwordResetRepository = JdbcPasswordResetRepository(it),
passwordHasher = { password ->
String(BCrypt.withDefaults().hash(10, metadata.salt.toByteArray(), password.toByteArray()))
},
permissionRepository = JdbcPermissionRepository(it),
recurringTransactionRepository = JdbcRecurringTransactionRepository(it),
sessionRepository = JdbcSessionRepository(it),
transactionRepository = JdbcTransactionRepository(it),
userRepository = JdbcUserRepository(it)
) )
} val jobs = listOf(
} SessionCleanupJob(sessionRepository),
RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository)
fun Application.moduleWithDependencies( )
emailService: EmailService, val sessionValidator: suspend ApplicationCall.(Session) -> Principal? = validate@{ session ->
metadataRepository: MetadataRepository,
budgetRepository: BudgetRepository,
categoryRepository: CategoryRepository,
passwordResetRepository: PasswordResetRepository,
passwordHasher: PasswordHasher,
permissionRepository: PermissionRepository,
recurringTransactionRepository: RecurringTransactionRepository,
sessionRepository: SessionRepository,
transactionRepository: TransactionRepository,
userRepository: UserRepository
) {
install(CallLogging)
install(Authentication) {
session<Session> {
challenge {
call.respond(HttpStatusCode.Unauthorized)
}
validate { session ->
application.environment.log.info("Validating session") application.environment.log.info("Validating session")
val storedSession = sessionRepository.findAll(session.token) val storedSession = sessionRepository.findAll(session.token)
.firstOrNull() .firstOrNull()
@ -140,6 +129,48 @@ fun Application.moduleWithDependencies(
null null
} }
} }
moduleWithDependencies(
budgetService = DefaultBudgetService(budgetRepository, permissionRepository),
categoryService = DefaultCategoryService(categoryRepository, permissionRepository),
recurringTransactionService = DefaultRecurringTransactionService(
recurringTransactionRepository,
permissionRepository
),
transactionService = DefaultTransactionService(
transactionRepository,
categoryRepository,
permissionRepository
),
userService = DefaultUserService(
emailService,
passwordResetRepository,
permissionRepository,
sessionRepository,
userRepository,
passwordHasher
),
jobs = jobs,
sessionValidator = sessionValidator
)
}
}
fun Application.moduleWithDependencies(
budgetService: BudgetService,
categoryService: CategoryService,
recurringTransactionService: RecurringTransactionService,
transactionService: TransactionService,
userService: UserService,
jobs: List<Job>,
sessionValidator: suspend ApplicationCall.(Session) -> Principal?
) {
install(CallLogging)
install(Authentication) {
session<Session> {
challenge {
call.respond(HttpStatusCode.Unauthorized)
}
validate(sessionValidator)
} }
} }
install(Sessions) { install(Sessions) {
@ -170,6 +201,8 @@ fun Application.moduleWithDependencies(
allowHost("twigs.wbrawner.com", listOf("http", "https")) // TODO: Make configurable allowHost("twigs.wbrawner.com", listOf("http", "https")) // TODO: Make configurable
allowHost("localhost:4200", listOf("http", "https")) // TODO: Make configurable allowHost("localhost:4200", listOf("http", "https")) // TODO: Make configurable
allowMethod(HttpMethod.Options) allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Put) allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete) allowMethod(HttpMethod.Delete)
allowHeader(HttpHeaders.Authorization) allowHeader(HttpHeaders.Authorization)
@ -192,17 +225,13 @@ fun Application.moduleWithDependencies(
allowHeader("DNT") allowHeader("DNT")
allowCredentials = true allowCredentials = true
} }
budgetRoutes(budgetRepository, permissionRepository) budgetRoutes(budgetService)
categoryRoutes(categoryRepository, permissionRepository) categoryRoutes(categoryService)
recurringTransactionRoutes(recurringTransactionRepository, permissionRepository) recurringTransactionRoutes(recurringTransactionService)
transactionRoutes(transactionRepository, permissionRepository) transactionRoutes(transactionService)
userRoutes(emailService, passwordResetRepository, permissionRepository, sessionRepository, userRepository, passwordHasher) userRoutes(userService)
webRoutes() webRoutes()
launch { launch {
val jobs = listOf(
SessionCleanupJob(sessionRepository),
RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository)
)
while (currentCoroutineContext().isActive) { while (currentCoroutineContext().isActive) {
jobs.forEach { it.run() } jobs.forEach { it.run() }
delay(TimeUnit.HOURS.toMillis(1)) delay(TimeUnit.HOURS.toMillis(1))

View file

@ -1,6 +1,11 @@
package com.wbrawner.twigs.server.api package com.wbrawner.twigs.server.api
import com.wbrawner.twigs.server.moduleWithDependencies import com.wbrawner.twigs.server.moduleWithDependencies
import com.wbrawner.twigs.service.budget.DefaultBudgetService
import com.wbrawner.twigs.service.category.DefaultCategoryService
import com.wbrawner.twigs.service.recurringtransaction.DefaultRecurringTransactionService
import com.wbrawner.twigs.service.transaction.DefaultTransactionService
import com.wbrawner.twigs.service.user.DefaultUserService
import com.wbrawner.twigs.test.helpers.FakeEmailService import com.wbrawner.twigs.test.helpers.FakeEmailService
import com.wbrawner.twigs.test.helpers.repository.* import com.wbrawner.twigs.test.helpers.repository.*
import io.ktor.client.* import io.ktor.client.*
@ -38,17 +43,29 @@ open class ApiTest {
fun apiTest(test: suspend ApiTest.(client: HttpClient) -> Unit) = testApplication { fun apiTest(test: suspend ApiTest.(client: HttpClient) -> Unit) = testApplication {
application { application {
moduleWithDependencies( moduleWithDependencies(
emailService = emailService, budgetService = DefaultBudgetService(budgetRepository, permissionRepository),
metadataRepository = metadataRepository, categoryService = DefaultCategoryService(categoryRepository, permissionRepository),
budgetRepository = budgetRepository, recurringTransactionService = DefaultRecurringTransactionService(
categoryRepository = categoryRepository, recurringTransactionRepository,
passwordHasher = { it }, permissionRepository
passwordResetRepository = passwordResetRepository, ),
permissionRepository = permissionRepository, transactionService = DefaultTransactionService(
recurringTransactionRepository = recurringTransactionRepository, transactionRepository,
sessionRepository = sessionRepository, categoryRepository,
transactionRepository = transactionRepository, permissionRepository
userRepository = userRepository ),
userService = DefaultUserService(
emailService,
passwordResetRepository,
permissionRepository,
sessionRepository,
userRepository,
{ it }
),
jobs = listOf(),
sessionValidator = {
sessionRepository.findAll(it.token).firstOrNull()
}
) )
} }
val client = createClient { val client = createClient {

View file

@ -1,15 +1,14 @@
package com.wbrawner.twigs.server.api package com.wbrawner.twigs.server.api
import com.wbrawner.twigs.BudgetRequest
import com.wbrawner.twigs.BudgetResponse
import com.wbrawner.twigs.UserPermissionRequest
import com.wbrawner.twigs.UserPermissionResponse
import com.wbrawner.twigs.model.* import com.wbrawner.twigs.model.*
import com.wbrawner.twigs.service.budget.BudgetRequest
import com.wbrawner.twigs.service.budget.BudgetResponse
import com.wbrawner.twigs.service.user.UserPermissionRequest
import com.wbrawner.twigs.service.user.UserPermissionResponse
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.http.* import io.ktor.http.*
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class BudgetRouteTest : ApiTest() { class BudgetRouteTest : ApiTest() {
@ -228,9 +227,8 @@ class BudgetRouteTest : ApiTest() {
assertEquals(expectedUsers, updatedUsers) assertEquals(expectedUsers, updatedUsers)
} }
@Disabled("Will be fixed with service layer refactor")
@Test @Test
fun `updating budgets returns not found for users with no access`() = apiTest { client -> fun `updating budgets returns forbidden for users with no access`() = apiTest { client ->
val users = listOf( val users = listOf(
User(name = "testuser", password = "testpassword"), User(name = "testuser", password = "testpassword"),
User(name = "otheruser", password = "otherpassword"), User(name = "otheruser", password = "otherpassword"),
@ -254,7 +252,6 @@ class BudgetRouteTest : ApiTest() {
assertEquals(HttpStatusCode.NotFound, response.status) assertEquals(HttpStatusCode.NotFound, response.status)
} }
@Disabled("Will be fixed with service layer refactor")
@Test @Test
fun `updating non-existent budgets returns not found`() = apiTest { client -> fun `updating non-existent budgets returns not found`() = apiTest { client ->
val users = listOf( val users = listOf(
@ -273,7 +270,6 @@ class BudgetRouteTest : ApiTest() {
assertEquals(HttpStatusCode.NotFound, response.status) assertEquals(HttpStatusCode.NotFound, response.status)
} }
@Disabled("Will be fixed with service layer refactor")
@Test @Test
fun `updating budgets returns forbidden for users with manage access attempting to remove owner`() = fun `updating budgets returns forbidden for users with manage access attempting to remove owner`() =
apiTest { client -> apiTest { client ->

View file

@ -1,10 +1,10 @@
package com.wbrawner.twigs.server.api package com.wbrawner.twigs.server.api
import com.wbrawner.twigs.ErrorResponse
import com.wbrawner.twigs.PasswordResetRequest
import com.wbrawner.twigs.ResetPasswordRequest
import com.wbrawner.twigs.model.PasswordResetToken import com.wbrawner.twigs.model.PasswordResetToken
import com.wbrawner.twigs.randomString import com.wbrawner.twigs.randomString
import com.wbrawner.twigs.service.ErrorResponse
import com.wbrawner.twigs.service.user.PasswordResetRequest
import com.wbrawner.twigs.service.user.ResetPasswordRequest
import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.TEST_USER import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.TEST_USER
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.request.* import io.ktor.client.request.*
@ -18,7 +18,7 @@ class PasswordResetRouteTest : ApiTest() {
@Test @Test
fun `reset password with invalid username returns 202`() = apiTest { client -> fun `reset password with invalid username returns 202`() = apiTest { client ->
val request = ResetPasswordRequest(username = "invaliduser") val request = ResetPasswordRequest(username = "invaliduser")
val response = client.post("/api/resetpassword") { val response = client.post("/api/users/resetpassword") {
header("Content-Type", "application/json") header("Content-Type", "application/json")
setBody(request) setBody(request)
} }
@ -29,7 +29,7 @@ class PasswordResetRouteTest : ApiTest() {
@Test @Test
fun `reset password with valid username returns 202`() = apiTest { client -> fun `reset password with valid username returns 202`() = apiTest { client ->
val request = ResetPasswordRequest(username = "testuser") val request = ResetPasswordRequest(username = "testuser")
val response = client.post("/api/resetpassword") { val response = client.post("/api/users/resetpassword") {
header("Content-Type", "application/json") header("Content-Type", "application/json")
setBody(request) setBody(request)
} }
@ -43,9 +43,9 @@ class PasswordResetRouteTest : ApiTest() {
} }
@Test @Test
fun `password reset with invalid token returns 400`() = apiTest { client -> fun `password reset with invalid token returns 401`() = apiTest { client ->
val request = PasswordResetRequest(token = randomString(), password = "newpass") val request = PasswordResetRequest(token = randomString(), password = "newpass")
val response = client.post("/api/passwordreset") { val response = client.put("/api/users/resetpassword") {
header("Content-Type", "application/json") header("Content-Type", "application/json")
setBody(request) setBody(request)
} }
@ -55,10 +55,10 @@ class PasswordResetRouteTest : ApiTest() {
} }
@Test @Test
fun `password reset with expired token returns 400`() = apiTest { client -> fun `password reset with expired token returns 401`() = apiTest { client ->
val token = passwordResetRepository.save(PasswordResetToken(expiration = twoWeeksAgo)) val token = passwordResetRepository.save(PasswordResetToken(expiration = twoWeeksAgo))
val request = PasswordResetRequest(token = token.id, password = "newpass") val request = PasswordResetRequest(token = token.id, password = "newpass")
val response = client.post("/api/passwordreset") { val response = client.put("/api/users/resetpassword") {
header("Content-Type", "application/json") header("Content-Type", "application/json")
setBody(request) setBody(request)
} }
@ -68,10 +68,11 @@ class PasswordResetRouteTest : ApiTest() {
} }
@Test @Test
fun `password reset with valid token returns 200`() = apiTest { client -> fun `password reset with valid token returns 204`() = apiTest { client ->
val token = passwordResetRepository.save(PasswordResetToken(userId = userRepository.findAll("testuser").first().id)) val token =
passwordResetRepository.save(PasswordResetToken(userId = userRepository.findAll("testuser").first().id))
val request = PasswordResetRequest(token = token.id, password = "newpass") val request = PasswordResetRequest(token = token.id, password = "newpass")
val response = client.post("/api/passwordreset") { val response = client.put("/api/users/resetpassword") {
header("Content-Type", "application/json") header("Content-Type", "application/json")
setBody(request) setBody(request)
} }

View file

@ -1,8 +1,9 @@
package com.wbrawner.twigs.server.api package com.wbrawner.twigs.server.api
import com.wbrawner.twigs.*
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.*
import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.OTHER_USER import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.OTHER_USER
import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.TEST_USER import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.TEST_USER
import io.ktor.client.call.* import io.ktor.client.call.*
@ -96,7 +97,7 @@ class UserRouteTest : ApiTest() {
} }
assertEquals(HttpStatusCode.BadRequest, response.status) assertEquals(HttpStatusCode.BadRequest, response.status)
val errorBody = response.body<ErrorResponse>() val errorBody = response.body<ErrorResponse>()
assertEquals("Username must not be null or blank", errorBody.message) assertEquals("username must not be null or blank", errorBody.message)
} }
@Test @Test
@ -108,7 +109,7 @@ class UserRouteTest : ApiTest() {
} }
assertEquals(HttpStatusCode.BadRequest, response.status) assertEquals(HttpStatusCode.BadRequest, response.status)
val errorBody = response.body<ErrorResponse>() val errorBody = response.body<ErrorResponse>()
assertEquals("Username must not be null or blank", errorBody.message) assertEquals("username must not be null or blank", errorBody.message)
} }
@Test @Test
@ -120,7 +121,7 @@ class UserRouteTest : ApiTest() {
} }
assertEquals(HttpStatusCode.BadRequest, response.status) assertEquals(HttpStatusCode.BadRequest, response.status)
val errorBody = response.body<ErrorResponse>() val errorBody = response.body<ErrorResponse>()
assertEquals("Password must not be null or blank", errorBody.message) assertEquals("password must not be null or blank", errorBody.message)
} }
@Test @Test
@ -132,7 +133,7 @@ class UserRouteTest : ApiTest() {
} }
assertEquals(HttpStatusCode.BadRequest, response.status) assertEquals(HttpStatusCode.BadRequest, response.status)
val errorBody = response.body<ErrorResponse>() val errorBody = response.body<ErrorResponse>()
assertEquals("Password must not be null or blank", errorBody.message) assertEquals("password must not be null or blank", errorBody.message)
} }
@Test @Test
@ -144,7 +145,7 @@ class UserRouteTest : ApiTest() {
} }
assertEquals(HttpStatusCode.BadRequest, response.status) assertEquals(HttpStatusCode.BadRequest, response.status)
val errorBody = response.body<ErrorResponse>() val errorBody = response.body<ErrorResponse>()
assertEquals("Username or email already taken", errorBody.message) assertEquals("username or email already taken", errorBody.message)
} }
@Test @Test
@ -156,7 +157,7 @@ class UserRouteTest : ApiTest() {
} }
assertEquals(HttpStatusCode.BadRequest, response.status) assertEquals(HttpStatusCode.BadRequest, response.status)
val errorBody = response.body<ErrorResponse>() val errorBody = response.body<ErrorResponse>()
assertEquals("Username or email already taken", errorBody.message) assertEquals("username or email already taken", errorBody.message)
} }
@Test @Test

View file

@ -2,6 +2,7 @@ package com.wbrawner.twigs
import com.wbrawner.twigs.model.Frequency import com.wbrawner.twigs.model.Frequency
import java.time.Instant import java.time.Instant
import java.time.format.DateTimeParseException
import java.util.* import java.util.*
private val CALENDAR_FIELDS = intArrayOf( private val CALENDAR_FIELDS = intArrayOf(
@ -52,4 +53,10 @@ fun randomString(length: Int = 32): String {
fun String.toInstant(): Instant = Instant.parse(this) fun String.toInstant(): Instant = Instant.parse(this)
fun String.toInstantOrNull(): Instant? = try {
Instant.parse(this)
} catch (e: DateTimeParseException) {
null
}
fun String.asFrequency(): Frequency = Frequency.parse(this) fun String.asFrequency(): Frequency = Frequency.parse(this)

View file

@ -13,7 +13,7 @@ class JdbcPermissionRepository(dataSource: DataSource) :
override val conflictFields: Collection<String> = override val conflictFields: Collection<String> =
listOf(Fields.USER_ID.name.lowercase(), Fields.BUDGET_ID.name.lowercase()) listOf(Fields.USER_ID.name.lowercase(), Fields.BUDGET_ID.name.lowercase())
override fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> = override suspend fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> =
dataSource.connection.use { conn -> dataSource.connection.use { conn ->
if (budgetIds.isNullOrEmpty() && userId.isNullOrBlank()) { if (budgetIds.isNullOrEmpty() && userId.isNullOrBlank()) {
throw Error("budgetIds or userId must be provided") throw Error("budgetIds or userId must be provided")

1
service/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
build/

View file

@ -1,21 +1,59 @@
package com.wbrawner.twigs.service package com.wbrawner.twigs.service
import com.wbrawner.twigs.model.Permission import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.model.UserPermission
import com.wbrawner.twigs.service.budget.BudgetResponse import com.wbrawner.twigs.service.budget.BudgetResponse
import com.wbrawner.twigs.storage.BudgetRepository import com.wbrawner.twigs.storage.BudgetRepository
import com.wbrawner.twigs.storage.PermissionRepository import com.wbrawner.twigs.storage.PermissionRepository
import io.ktor.http.* 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 fun PermissionRepository.requirePermission(
userId: String,
budgetIds: List<String>,
permission: Permission
): List<UserPermission> {
val uniqueBudgetIds = budgetIds.toSet()
val allPermissions = findAll(budgetIds = uniqueBudgetIds.toList(), userId = userId)
if (allPermissions.size != uniqueBudgetIds.size) {
throw HttpException(HttpStatusCode.NotFound)
} else if (allPermissions.any { !it.permission.isAtLeast(permission) }) {
throw HttpException(HttpStatusCode.Forbidden)
}
return allPermissions
}
suspend fun PermissionRepository.requirePermission(
userId: String,
budgetId: String,
permission: Permission
): List<UserPermission> = requirePermission(userId, listOf(budgetId), permission)
suspend fun Pair<BudgetRepository, PermissionRepository>.budgetWithPermission( suspend fun Pair<BudgetRepository, PermissionRepository>.budgetWithPermission(
userId: String, userId: String,
budgetId: String, budgetId: String,
permission: Permission permission: Permission
): BudgetResponse { ): BudgetResponse {
val allPermissions = second.findAll(budgetIds = listOf(budgetId)) val budget = first.findAll(ids = listOf(budgetId)).firstOrNull() ?: throw HttpException(HttpStatusCode.NotFound)
val userPermission = allPermissions.firstOrNull { it.userId == userId } return BudgetResponse(budget, second.requirePermission(userId, budgetId, permission))
?: throw HttpException(HttpStatusCode.NotFound)
if (!userPermission.permission.isAtLeast(permission)) {
throw HttpException(HttpStatusCode.Forbidden)
}
return BudgetResponse(first.findAll(ids = listOf(budgetId)).first(), allPermissions)
} }
fun PipelineContext<Unit, ApplicationCall>.requireSession() = requireNotNull(call.principal<Session>()) {
"Session required but was null"
}
suspend inline fun <reified T : Any> ApplicationCall.respondCatching(block: () -> T) =
try {
val response = block()
if (response is HttpStatusCode) {
respond(status = response, message = Unit)
} else {
respond(HttpStatusCode.OK, response)
}
} catch (e: HttpException) {
respond(e.statusCode, e.toResponse())
}

View file

@ -0,0 +1,104 @@
package com.wbrawner.twigs.service.category
import com.wbrawner.twigs.model.Category
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.service.requirePermission
import com.wbrawner.twigs.storage.CategoryRepository
import com.wbrawner.twigs.storage.PermissionRepository
import io.ktor.http.*
interface CategoryService {
suspend fun categories(
budgetIds: List<String>,
userId: String,
expense: Boolean? = null,
archived: Boolean? = null,
): List<CategoryResponse>
suspend fun category(categoryId: String, userId: String): CategoryResponse
suspend fun save(request: CategoryRequest, userId: String, categoryId: String? = null): CategoryResponse
suspend fun delete(categoryId: String, userId: String)
}
class DefaultCategoryService(
private val categoryRepository: CategoryRepository,
private val permissionRepository: PermissionRepository
) : CategoryService {
override suspend fun categories(
budgetIds: List<String>,
userId: String,
expense: Boolean?,
archived: Boolean?,
): List<CategoryResponse> {
val validBudgetIds = permissionRepository.findAll(
budgetIds = budgetIds,
userId = userId
).map { it.budgetId }
if (validBudgetIds.isEmpty()) {
return emptyList()
}
return categoryRepository.findAll(
budgetIds = budgetIds,
expense = expense,
archived = archived
).map { it.asResponse() }
}
override suspend fun category(categoryId: String, userId: String): CategoryResponse {
val budgetIds = permissionRepository.findAll(userId = userId).map { it.budgetId }
if (budgetIds.isEmpty()) {
throw HttpException(HttpStatusCode.NotFound)
}
return categoryRepository.findAll(
ids = listOf(categoryId),
budgetIds = budgetIds
)
.map { it.asResponse() }
.firstOrNull()
?: throw HttpException(HttpStatusCode.NotFound)
}
override suspend fun save(request: CategoryRequest, userId: String, categoryId: String?): CategoryResponse {
val category = categoryId?.let {
categoryRepository.findAll(ids = listOf(categoryId)).firstOrNull()
?: throw HttpException(HttpStatusCode.NotFound)
} ?: run {
if (request.title.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "title cannot be null or empty")
}
if (request.budgetId.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "budgetId cannot be null or empty")
}
Category(
title = request.title,
description = request.description,
amount = request.amount ?: 0L,
expense = request.expense ?: true,
budgetId = request.budgetId
)
}
permissionRepository.requirePermission(userId, category.budgetId, Permission.WRITE)
return categoryRepository.save(
category.copy(
title = request.title?.ifBlank { category.title } ?: category.title,
description = request.description ?: category.description,
amount = request.amount ?: category.amount,
expense = request.expense ?: category.expense,
archived = request.archived ?: category.archived,
budgetId = request.budgetId?.ifBlank { category.budgetId } ?: category.budgetId
)
).asResponse()
}
override suspend fun delete(categoryId: String, userId: String) {
val category = categoryRepository.findAll(ids = listOf(categoryId))
.firstOrNull()
?: throw HttpException(HttpStatusCode.NotFound)
permissionRepository.requirePermission(userId, category.budgetId, Permission.WRITE)
categoryRepository.delete(category)
}
}

View file

@ -0,0 +1,110 @@
package com.wbrawner.twigs.service.recurringtransaction
import com.wbrawner.twigs.asFrequency
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.RecurringTransaction
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.service.requirePermission
import com.wbrawner.twigs.storage.PermissionRepository
import com.wbrawner.twigs.storage.RecurringTransactionRepository
import com.wbrawner.twigs.toInstant
import io.ktor.http.*
import java.time.Instant
interface RecurringTransactionService {
suspend fun recurringTransactions(
budgetId: String,
userId: String,
): List<RecurringTransactionResponse>
suspend fun recurringTransaction(recurringTransactionId: String, userId: String): RecurringTransactionResponse
suspend fun save(
request: RecurringTransactionRequest,
userId: String,
recurringTransactionId: String? = null
): RecurringTransactionResponse
suspend fun delete(recurringTransactionId: String, userId: String)
}
class DefaultRecurringTransactionService(
private val recurringTransactionRepository: RecurringTransactionRepository,
private val permissionRepository: PermissionRepository
) : RecurringTransactionService {
override suspend fun recurringTransactions(
budgetId: String,
userId: String
): List<RecurringTransactionResponse> {
permissionRepository.requirePermission(userId, budgetId, Permission.READ)
return recurringTransactionRepository.findAll(budgetId = budgetId)
.map { it.asResponse() }
}
override suspend fun recurringTransaction(
recurringTransactionId: String,
userId: String
): RecurringTransactionResponse {
val recurringTransaction = recurringTransactionRepository.findAll(ids = listOf(recurringTransactionId))
.firstOrNull()
?: throw HttpException(HttpStatusCode.NotFound)
permissionRepository.requirePermission(userId, recurringTransaction.budgetId, Permission.READ)
return recurringTransaction.asResponse()
}
override suspend fun save(
request: RecurringTransactionRequest,
userId: String,
recurringTransactionId: String?
): RecurringTransactionResponse {
val recurringTransaction = recurringTransactionId?.let {
recurringTransactionRepository.findAll(ids = listOf(it))
.firstOrNull()
?.also { recurringTransaction ->
permissionRepository.requirePermission(userId, recurringTransaction.budgetId, Permission.WRITE)
}
?: throw HttpException(HttpStatusCode.NotFound)
} ?: run {
if (request.title.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "title cannot be null or empty")
}
if (request.budgetId.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "budgetId cannot be null or empty")
}
RecurringTransaction(
title = request.title,
description = request.description,
amount = request.amount ?: 0L,
expense = request.expense ?: true,
budgetId = request.budgetId,
categoryId = request.categoryId,
createdBy = userId,
start = request.start?.toInstant() ?: Instant.now(),
finish = request.finish?.toInstant(),
frequency = request.frequency.asFrequency()
)
}
permissionRepository.requirePermission(userId, recurringTransaction.budgetId, Permission.WRITE)
return recurringTransactionRepository.save(
recurringTransaction.copy(
title = request.title?.ifBlank { recurringTransaction.title } ?: recurringTransaction.title,
description = request.description ?: recurringTransaction.description,
amount = request.amount ?: recurringTransaction.amount,
expense = request.expense ?: recurringTransaction.expense,
budgetId = request.budgetId?.ifBlank { recurringTransaction.budgetId } ?: recurringTransaction.budgetId,
categoryId = request.categoryId ?: recurringTransaction.categoryId,
start = request.start?.toInstant() ?: recurringTransaction.start,
finish = request.finish?.toInstant() ?: recurringTransaction.finish,
frequency = request.frequency.asFrequency()
)
).asResponse()
}
override suspend fun delete(recurringTransactionId: String, userId: String) {
val recurringTransaction = recurringTransactionRepository.findAll(ids = listOf(recurringTransactionId))
.firstOrNull()
?: throw HttpException(HttpStatusCode.NotFound)
permissionRepository.requirePermission(userId, recurringTransaction.budgetId, Permission.WRITE)
recurringTransactionRepository.delete(recurringTransaction)
}
}

View file

@ -0,0 +1,167 @@
package com.wbrawner.twigs.service.transaction
import com.wbrawner.twigs.endOfMonth
import com.wbrawner.twigs.firstOfMonth
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.Transaction
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.service.requirePermission
import com.wbrawner.twigs.storage.CategoryRepository
import com.wbrawner.twigs.storage.PermissionRepository
import com.wbrawner.twigs.storage.TransactionRepository
import com.wbrawner.twigs.toInstant
import com.wbrawner.twigs.toInstantOrNull
import io.ktor.http.*
import java.time.Instant
interface TransactionService {
suspend fun transactions(
budgetIds: List<String>,
categoryIds: List<String>?,
from: Instant?,
to: Instant?,
expense: Boolean?,
userId: String,
): List<TransactionResponse>
suspend fun transaction(transactionId: String, userId: String): TransactionResponse
suspend fun sum(
budgetId: String?,
categoryId: String?,
from: Instant?,
to: Instant?,
userId: String,
): Long
suspend fun save(
request: TransactionRequest,
userId: String,
transactionId: String? = null
): TransactionResponse
suspend fun delete(transactionId: String, userId: String)
}
class DefaultTransactionService(
private val transactionRepository: TransactionRepository,
private val categoryRepository: CategoryRepository,
private val permissionRepository: PermissionRepository
) : TransactionService {
override suspend fun transactions(
budgetIds: List<String>,
categoryIds: List<String>?,
from: Instant?,
to: Instant?,
expense: Boolean?,
userId: String
): List<TransactionResponse> {
permissionRepository.requirePermission(userId, budgetIds, Permission.READ)
return transactionRepository.findAll(
budgetIds = budgetIds,
categoryIds = categoryIds,
from = from,
to = to,
expense = expense
).map { it.asResponse() }
}
override suspend fun transaction(
transactionId: String,
userId: String
): TransactionResponse {
val transaction = transactionRepository.findAll(ids = listOf(transactionId))
.firstOrNull()
?: throw HttpException(HttpStatusCode.NotFound)
permissionRepository.requirePermission(userId, transaction.budgetId, Permission.READ)
return transaction.asResponse()
}
override suspend fun sum(
budgetId: String?,
categoryId: String?,
from: Instant?,
to: Instant?,
userId: String
): Long {
if (budgetId.isNullOrBlank() && categoryId.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "budgetId or categoryId must be provided to sum")
}
if (budgetId?.isNotBlank() == true && categoryId?.isNotBlank() == true) {
throw HttpException(
HttpStatusCode.BadRequest,
message = "budgetId and categoryId cannot be provided together"
)
}
return if (!categoryId.isNullOrBlank()) {
val category = categoryRepository.findAll(ids = listOf(categoryId)).firstOrNull()
?: throw HttpException(HttpStatusCode.NotFound)
permissionRepository.requirePermission(
userId = userId,
budgetId = category.budgetId,
permission = Permission.READ
)
transactionRepository.sumByCategory(category.id, from ?: firstOfMonth, to ?: endOfMonth)
} else if (!budgetId.isNullOrBlank()) {
permissionRepository.requirePermission(userId = userId, budgetId = budgetId, permission = Permission.READ)
transactionRepository.sumByBudget(budgetId, from ?: firstOfMonth, to ?: endOfMonth)
} else {
error("Somehow we didn't return either a budget or category sum")
}
}
override suspend fun save(
request: TransactionRequest,
userId: String,
transactionId: String?
): TransactionResponse {
val transaction = transactionId?.let {
transactionRepository.findAll(ids = listOf(it))
.firstOrNull()
?.also { transaction ->
permissionRepository.requirePermission(userId, transaction.budgetId, Permission.WRITE)
}
?: throw HttpException(HttpStatusCode.NotFound)
} ?: run {
if (request.title.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "title cannot be null or empty")
}
if (request.budgetId.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "budgetId cannot be null or empty")
}
if (request.date?.toInstantOrNull() == null) {
throw HttpException(HttpStatusCode.BadRequest, message = "invalid date")
}
Transaction(
title = request.title,
description = request.description,
amount = request.amount ?: 0L,
expense = request.expense ?: true,
budgetId = request.budgetId,
categoryId = request.categoryId,
date = request.date.toInstant(),
createdBy = userId,
)
}
permissionRepository.requirePermission(userId, request.budgetId ?: transaction.budgetId, Permission.WRITE)
return transactionRepository.save(
transaction.copy(
title = request.title?.ifBlank { transaction.title } ?: transaction.title,
description = request.description ?: transaction.description,
amount = request.amount ?: transaction.amount,
expense = request.expense ?: transaction.expense,
budgetId = request.budgetId?.ifBlank { transaction.budgetId } ?: transaction.budgetId,
categoryId = request.categoryId ?: transaction.categoryId,
date = request.date?.toInstantOrNull() ?: transaction.date
)
).asResponse()
}
override suspend fun delete(transactionId: String, userId: String) {
val transaction = transactionRepository.findAll(ids = listOf(transactionId))
.firstOrNull()
?: throw HttpException(HttpStatusCode.NotFound)
permissionRepository.requirePermission(userId, transaction.budgetId, Permission.WRITE)
transactionRepository.delete(transaction)
}
}

View file

@ -0,0 +1,169 @@
package com.wbrawner.twigs.service.user
import com.wbrawner.twigs.EmailService
import com.wbrawner.twigs.model.PasswordResetToken
import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.model.User
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.storage.*
import io.ktor.http.*
import java.time.Instant
interface UserService {
suspend fun login(request: LoginRequest): SessionResponse
suspend fun register(request: UserRequest): UserResponse
suspend fun requestPasswordResetEmail(request: ResetPasswordRequest)
suspend fun resetPassword(request: PasswordResetRequest)
suspend fun users(query: String?, budgetIds: List<String>?, requestingUserId: String): List<UserResponse>
suspend fun user(userId: String): UserResponse
suspend fun save(request: UserRequest, targetUserId: String, requestingUserId: String): UserResponse
suspend fun delete(targetUserId: String, requestingUserId: String)
}
class DefaultUserService(
private val emailService: EmailService,
private val passwordResetRepository: PasswordResetRepository,
private val permissionRepository: PermissionRepository,
private val sessionRepository: SessionRepository,
private val userRepository: UserRepository,
private val passwordHasher: PasswordHasher
) : UserService {
override suspend fun login(request: LoginRequest): SessionResponse {
val user = userRepository.findAll(
nameOrEmail = request.username,
password = passwordHasher.hash(request.password)
)
.firstOrNull()
?: throw HttpException(HttpStatusCode.Unauthorized, message = "Invalid credentials")
return sessionRepository.save(Session(userId = user.id)).asResponse()
}
override suspend fun register(request: UserRequest): UserResponse {
if (request.username.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "username must not be null or blank")
}
if (request.password.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "password must not be null or blank")
}
val existingUser = userRepository.findAll(nameOrEmail = request.username).firstOrNull()
?: request.email?.let {
if (it.isBlank()) {
null
} else {
userRepository.findAll(nameOrEmail = it).firstOrNull()
}
}
existingUser?.let {
throw HttpException(HttpStatusCode.BadRequest, message = "username or email already taken")
}
return userRepository.save(
User(
name = request.username,
password = passwordHasher.hash(request.password),
email = if (request.email.isNullOrBlank()) "" else request.email
)
).asResponse()
}
override suspend fun requestPasswordResetEmail(request: ResetPasswordRequest) {
userRepository.findAll(nameOrEmail = request.username)
.firstOrNull()
?.let {
val email = it.email
val passwordResetToken = passwordResetRepository.save(PasswordResetToken(userId = it.id))
emailService.sendPasswordResetEmail(passwordResetToken, email)
}
}
override suspend fun resetPassword(request: PasswordResetRequest) {
val passwordResetToken = passwordResetRepository.findAll(listOf(request.token))
.firstOrNull()
?: throw HttpException(HttpStatusCode.Unauthorized, message = "Invalid token")
if (passwordResetToken.expiration.isBefore(Instant.now())) {
throw HttpException(HttpStatusCode.Unauthorized, message = "Token expired")
}
if (request.password.isBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "password cannot be empty")
}
userRepository.findAll(listOf(passwordResetToken.userId))
.firstOrNull()
?.let {
userRepository.save(it.copy(password = passwordHasher.hash(request.password)))
passwordResetRepository.delete(passwordResetToken)
}
?: throw HttpException(HttpStatusCode.InternalServerError, message = "Invalid token")
}
override suspend fun users(
query: String?,
budgetIds: List<String>?,
requestingUserId: String
): List<UserResponse> {
if (query != null) {
if (query.isBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "query cannot be empty")
}
return userRepository.findAll(nameLike = query).map { it.asResponse() }
} else if (budgetIds == null || budgetIds.all { it.isBlank() }) {
throw HttpException(HttpStatusCode.BadRequest, message = "query or budgetId required but absent")
}
return permissionRepository.findAll(budgetIds = budgetIds, userId = requestingUserId)
.mapNotNull {
userRepository.findAll(ids = listOf(it.userId))
.firstOrNull()
?.asResponse()
}
}
override suspend fun user(
userId: String
): UserResponse {
return userRepository.findAll(ids = listOf(userId))
.firstOrNull()
?.asResponse()
?: throw HttpException(HttpStatusCode.NotFound)
}
override suspend fun save(
request: UserRequest,
targetUserId: String,
requestingUserId: String,
): UserResponse {
// TODO: Add some kind of admin denotation to allow admins to edit other users
if (targetUserId != requestingUserId) {
throw HttpException(HttpStatusCode.Forbidden)
}
return userRepository.save(
userRepository.findAll(ids = listOf(targetUserId))
.first()
.run {
val newPassword = if (request.password.isNullOrBlank()) {
password
} else {
passwordHasher.hash(request.password)
}
copy(
name = request.username ?: name,
password = newPassword,
email = request.email ?: email
)
}
).asResponse()
}
override suspend fun delete(targetUserId: String, requestingUserId: String) {
// TODO: Add some kind of admin denotation to allow admins to delete other users
if (targetUserId != requestingUserId) {
throw HttpException(HttpStatusCode.Forbidden)
}
userRepository.delete(userRepository.findAll(targetUserId).first())
}
}

View file

@ -3,7 +3,7 @@ package com.wbrawner.twigs.storage
import com.wbrawner.twigs.model.UserPermission import com.wbrawner.twigs.model.UserPermission
interface PermissionRepository : Repository<UserPermission> { interface PermissionRepository : Repository<UserPermission> {
fun findAll( suspend fun findAll(
budgetIds: List<String>? = null, budgetIds: List<String>? = null,
userId: String? = null userId: String? = null
): List<UserPermission> ): List<UserPermission>

View file

@ -5,7 +5,7 @@ import com.wbrawner.twigs.storage.PermissionRepository
class FakePermissionRepository : PermissionRepository { class FakePermissionRepository : PermissionRepository {
val permissions: MutableList<UserPermission> = mutableListOf() val permissions: MutableList<UserPermission> = mutableListOf()
override fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> = override suspend fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> =
permissions.filter { userPermission -> permissions.filter { userPermission ->
budgetIds?.contains(userPermission.budgetId) ?: true budgetIds?.contains(userPermission.budgetId) ?: true
&& userId?.let { it == userPermission.userId } ?: true && userId?.let { it == userPermission.userId } ?: true