diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 171895a..dde5fa3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,25 +2,28 @@ name: Build on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: check-kotlin-code-format: name: Check Kotlin code format runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - name: Checkout sources + uses: actions/checkout@v4 - - name: Download ktlint binary - run: | - curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.2.1/ktlint - chmod +x ktlint - cp ktlint android/ktlint + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: gradle - name: Run ktlint - run: cd android && ./ktlint --editorconfig=".editorconfig" + working-directory: android + run: ./gradlew ktlintCheck check-scala-code-format: name: Check Scala code format @@ -42,41 +45,68 @@ jobs: uses: coursier/cache-action@v6 - name: Run scalafmt check - run: cd backend && sbt scalafmtCheck + working-directory: backend + run: sbt scalafmtCheckAll - build-android-app: - name: Build Android app - needs: [ check-kotlin-code-format ] + verify-openapi-schema: + name: Verify OpenAPI schema + needs: [check-scala-code-format] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - name: Checkout sources + uses: actions/checkout@v4 - - uses: actions/setup-java@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v4 with: - distribution: 'zulu' + distribution: temurin java-version: 17 - name: Setup SBT uses: sbt/setup-sbt@v1 - - name: Cache Dependencies + - name: Cache dependencies uses: coursier/cache-action@v6 - - name: Build API jar - run: cd android && ./setup-api.sh + - name: Generate OpenAPI schema + working-directory: backend + run: sbt generateOpenApi + + - name: Check OpenAPI schema is up to date + run: git diff --exit-code -- backend/openapi-schema/openapi.yaml + + - name: Test OpenAPI schema + working-directory: backend + run: sbt openapiSchema/test + + build-android-app: + name: Build Android app + needs: [check-kotlin-code-format, verify-openapi-schema] + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: gradle - name: Build debug apk - run: cd android && ./gradlew app:assembleDebug + working-directory: android + run: ./gradlew :app:assembleDebug - name: Store APK file uses: actions/upload-artifact@v4 with: name: app-debug.apk - path: ./android/app/build/outputs/apk/debug/app-debug.apk + path: android/app/build/outputs/apk/debug/app-debug.apk build-backend-app: name: Build Backend app - needs: [ check-scala-code-format ] + needs: [check-scala-code-format, verify-openapi-schema] runs-on: ubuntu-latest steps: - name: Checkout sources @@ -91,20 +121,23 @@ jobs: - name: Setup SBT uses: sbt/setup-sbt@v1 - - name: Cache Dependencies + - name: Cache dependencies uses: coursier/cache-action@v6 - name: Run sbt compile - run: cd backend && sbt app/compile + working-directory: backend + run: sbt app/compile - name: Run tests - run: cd backend && sbt app/test + working-directory: backend + run: sbt test - name: Build jar file - run: cd backend && sbt app/assembly + working-directory: backend + run: sbt app/assembly - name: Store jar file uses: actions/upload-artifact@v4 with: name: simple-split-backend.jar - path: ./backend/app/target/scala-3.7.1/simple-split-backend.jar + path: backend/app/target/scala-3.7.1/simple-split-backend.jar diff --git a/.gitignore b/.gitignore index 1f47ad7..a358eda 100644 --- a/.gitignore +++ b/.gitignore @@ -71,7 +71,7 @@ out/ # Development and application files backend/app-data -backend/dev-data +backend/keys/ # Env files .env diff --git a/android/.editorconfig b/android/.editorconfig index fc2f969..3a9d2f0 100644 --- a/android/.editorconfig +++ b/android/.editorconfig @@ -15,13 +15,15 @@ indent_style = space insert_final_newline = false ktlint_argument_list_wrapping_ignore_when_parameter_count_greater_or_equal_than = 8 ktlint_chain_method_rule_force_multiline_when_chain_operator_count_greater_or_equal_than = 2 -ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = unset +ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 1 ktlint_code_style = android_studio ktlint_function_naming_ignore_when_annotated_with = Composable ktlint_function_signature_body_expression_wrapping = multiline +ktlint_standard_blank-line-between-when-conditions = disabled +ktlint_standard_function-expression-body = disabled ktlint_function_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 2 ktlint_ignore_back_ticked_identifier = false max_line_length = 100 -[backend-api/**/*] -ktlint = disabled \ No newline at end of file +[backend-api/**/*.{kt,kts}] +ktlint = disabled diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index df89be3..1df6ea0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.parcelize) alias(libs.plugins.kapt) alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.ktlint) } fun getVersionName(): String = libs.versions.appVersion.get() @@ -145,7 +146,7 @@ dependencies { implementation(libs.decompose.extensions) // Api - implementation(files("libs/simple-split-api.jar")) + implementation(project(":backend-api")) // Preferences implementation(libs.ksprefs) diff --git a/android/app/libs/simple-split-api.jar b/android/app/libs/simple-split-api.jar deleted file mode 100644 index 4967980..0000000 Binary files a/android/app/libs/simple-split-api.jar and /dev/null differ diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/ApiClient.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/ApiClient.kt index 4e9bdcf..a0c43b1 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/ApiClient.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/ApiClient.kt @@ -5,22 +5,22 @@ import com.github.ai.simplesplit.android.data.json.JsonSerializer import com.github.ai.simplesplit.android.data.settings.Settings import com.github.ai.simplesplit.android.model.exception.ApiException import com.github.ai.simplesplit.android.utils.atomicReference -import com.github.ai.split.api.request.PostExpenseRequest -import com.github.ai.split.api.request.PostGroupRequest -import com.github.ai.split.api.request.PostMemberRequest -import com.github.ai.split.api.request.PutExpenseRequest -import com.github.ai.split.api.request.PutGroupRequest -import com.github.ai.split.api.request.PutMemberRequest -import com.github.ai.split.api.response.DeleteExpenseResponse -import com.github.ai.split.api.response.DeleteMemberResponse -import com.github.ai.split.api.response.GetCurrenciesResponse -import com.github.ai.split.api.response.GetGroupsResponse -import com.github.ai.split.api.response.PostExpenseResponse -import com.github.ai.split.api.response.PostGroupResponse -import com.github.ai.split.api.response.PostMemberResponse -import com.github.ai.split.api.response.PutExpenseResponse -import com.github.ai.split.api.response.PutGroupResponse -import com.github.ai.split.api.response.PutMemberResponse +import com.github.ai.split.api.DeleteExpenseResponse +import com.github.ai.split.api.DeleteMemberResponse +import com.github.ai.split.api.GetCurrenciesResponse +import com.github.ai.split.api.GetGroupsResponse +import com.github.ai.split.api.PostExpenseRequest +import com.github.ai.split.api.PostExpenseResponse +import com.github.ai.split.api.PostGroupRequest +import com.github.ai.split.api.PostGroupResponse +import com.github.ai.split.api.PostMemberRequest +import com.github.ai.split.api.PostMemberResponse +import com.github.ai.split.api.PutExpenseRequest +import com.github.ai.split.api.PutExpenseResponse +import com.github.ai.split.api.PutGroupRequest +import com.github.ai.split.api.PutGroupResponse +import com.github.ai.split.api.PutMemberRequest +import com.github.ai.split.api.PutMemberResponse import io.ktor.client.HttpClient class ApiClient( diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/ExpenseRepository.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/ExpenseRepository.kt index 1c6f384..aa4e231 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/ExpenseRepository.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/ExpenseRepository.kt @@ -3,11 +3,11 @@ package com.github.ai.simplesplit.android.data.repository import arrow.core.Either import com.github.ai.simplesplit.android.data.api.ApiClient import com.github.ai.simplesplit.android.model.exception.AppException -import com.github.ai.split.api.request.PostExpenseRequest -import com.github.ai.split.api.request.PutExpenseRequest -import com.github.ai.split.api.response.DeleteExpenseResponse -import com.github.ai.split.api.response.PostExpenseResponse -import com.github.ai.split.api.response.PutExpenseResponse +import com.github.ai.split.api.DeleteExpenseResponse +import com.github.ai.split.api.PostExpenseRequest +import com.github.ai.split.api.PostExpenseResponse +import com.github.ai.split.api.PutExpenseRequest +import com.github.ai.split.api.PutExpenseResponse class ExpenseRepository( private val api: ApiClient diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/GroupRepository.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/GroupRepository.kt index a06c10f..5cacfb7 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/GroupRepository.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/GroupRepository.kt @@ -6,10 +6,10 @@ import com.github.ai.simplesplit.android.data.api.ApiClient import com.github.ai.simplesplit.android.model.exception.ApiException import com.github.ai.simplesplit.android.model.exception.AppException import com.github.ai.split.api.GroupDto -import com.github.ai.split.api.request.PostGroupRequest -import com.github.ai.split.api.request.PutGroupRequest -import com.github.ai.split.api.response.PostGroupResponse -import com.github.ai.split.api.response.PutGroupResponse +import com.github.ai.split.api.PostGroupRequest +import com.github.ai.split.api.PostGroupResponse +import com.github.ai.split.api.PutGroupRequest +import com.github.ai.split.api.PutGroupResponse class GroupRepository( private val api: ApiClient diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/MemberRepository.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/MemberRepository.kt index ae4acce..1550985 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/MemberRepository.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/MemberRepository.kt @@ -3,11 +3,11 @@ package com.github.ai.simplesplit.android.data.repository import arrow.core.Either import com.github.ai.simplesplit.android.data.api.ApiClient import com.github.ai.simplesplit.android.model.exception.AppException -import com.github.ai.split.api.request.PostMemberRequest -import com.github.ai.split.api.request.PutMemberRequest -import com.github.ai.split.api.response.DeleteMemberResponse -import com.github.ai.split.api.response.PostMemberResponse -import com.github.ai.split.api.response.PutMemberResponse +import com.github.ai.split.api.DeleteMemberResponse +import com.github.ai.split.api.PostMemberRequest +import com.github.ai.split.api.PostMemberResponse +import com.github.ai.split.api.PutMemberRequest +import com.github.ai.split.api.PutMemberResponse class MemberRepository( private val api: ApiClient diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/model/BottomSheetHeaderCellEvent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/model/BottomSheetHeaderCellEvent.kt index 73b4c83..1f29528 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/model/BottomSheetHeaderCellEvent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/model/BottomSheetHeaderCellEvent.kt @@ -3,5 +3,7 @@ package com.github.ai.simplesplit.android.presentation.core.compose.cells.model import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellEvent sealed interface BottomSheetHeaderCellEvent : CellEvent { - data class OnIconClick(val cellId: String) : BottomSheetHeaderCellEvent + data class OnIconClick( + val cellId: String + ) : BottomSheetHeaderCellEvent } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/model/ButtonCellEvent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/model/ButtonCellEvent.kt index 2a8d6ce..ebb00d3 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/model/ButtonCellEvent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/model/ButtonCellEvent.kt @@ -3,5 +3,7 @@ package com.github.ai.simplesplit.android.presentation.core.compose.cells.model import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellEvent sealed interface ButtonCellEvent : CellEvent { - data class OnClick(val cellId: String) : ButtonCellEvent + data class OnClick( + val cellId: String + ) : ButtonCellEvent } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/model/MenuCellEvent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/model/MenuCellEvent.kt index 00bd163..4e03175 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/model/MenuCellEvent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/model/MenuCellEvent.kt @@ -3,5 +3,7 @@ package com.github.ai.simplesplit.android.presentation.core.compose.cells.model import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellEvent sealed interface MenuCellEvent : CellEvent { - data class OnClick(val cellId: String) : MenuCellEvent + data class OnClick( + val cellId: String + ) : MenuCellEvent } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/mvi/MviViewModel.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/mvi/MviViewModel.kt index 9469957..403156f 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/mvi/MviViewModel.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/mvi/MviViewModel.kt @@ -20,7 +20,8 @@ import kotlinx.coroutines.runBlocking abstract class MviViewModel( initialState: State, private val initialIntent: Intent -) : ViewModel(), ScreenViewModel { +) : ViewModel(), + ScreenViewModel { val state = MutableStateFlow(initialState) private val intents = Channel(capacity = Channel.BUFFERED) diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/model/SelectCurrencyDialogIntent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/model/SelectCurrencyDialogIntent.kt index 3546d5f..71a5f53 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/model/SelectCurrencyDialogIntent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/model/SelectCurrencyDialogIntent.kt @@ -8,7 +8,13 @@ sealed class SelectCurrencyDialogIntent( data object Initialize : SelectCurrencyDialogIntent() data object Dismiss : SelectCurrencyDialogIntent() data object OnResetQueryClick : SelectCurrencyDialogIntent() - data class OnQueryTextChange(val query: String) : SelectCurrencyDialogIntent(isImmediate = true) - data class OnCurrencySelected(val currencyIsoCode: String) : SelectCurrencyDialogIntent() - data class FilterCurrencies(val query: String) : SelectCurrencyDialogIntent() + data class OnQueryTextChange( + val query: String + ) : SelectCurrencyDialogIntent(isImmediate = true) + data class OnCurrencySelected( + val currencyIsoCode: String + ) : SelectCurrencyDialogIntent() + data class FilterCurrencies( + val query: String + ) : SelectCurrencyDialogIntent() } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/model/CheckoutGroupIntent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/model/CheckoutGroupIntent.kt index da09237..dfb0805 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/model/CheckoutGroupIntent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/checkoutGroup/model/CheckoutGroupIntent.kt @@ -9,5 +9,7 @@ sealed class CheckoutGroupIntent( data object OnBackClick : CheckoutGroupIntent() data object OnDoneClick : CheckoutGroupIntent() data object OnCloseErrorClick : CheckoutGroupIntent() - data class OnUrlChanged(val url: String) : CheckoutGroupIntent(isImmediate = true) + data class OnUrlChanged( + val url: String + ) : CheckoutGroupIntent(isImmediate = true) } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorInteractor.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorInteractor.kt index 63c5303..df0e395 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorInteractor.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorInteractor.kt @@ -7,9 +7,9 @@ import com.github.ai.simplesplit.android.data.repository.ExpenseRepository import com.github.ai.simplesplit.android.data.repository.GroupCredentialsRepository import com.github.ai.simplesplit.android.model.exception.AppException import com.github.ai.split.api.ExpenseDto +import com.github.ai.split.api.PostExpenseRequest +import com.github.ai.split.api.PutExpenseRequest import com.github.ai.split.api.UserUidDto -import com.github.ai.split.api.request.PostExpenseRequest -import com.github.ai.split.api.request.PutExpenseRequest class ExpenseEditorInteractor( private val expenseRepository: ExpenseRepository, @@ -25,12 +25,12 @@ class ExpenseEditorInteractor( ): Either = either { val request = PutExpenseRequest( - title, - null, - amount, - if (payerUid != null) listOf(UserUidDto(payerUid)) else emptyList(), - true, - null + title = title, + description = null, + amount = amount, + paidBy = if (payerUid != null) listOf(UserUidDto(payerUid)) else emptyList(), + isSplitBetweenAll = true, + splitBetween = null ) expenseRepository.updateExpense( @@ -51,13 +51,13 @@ class ExpenseEditorInteractor( ?: raise(AppException(message = "Failed to load credentials for group")) val request = PostExpenseRequest( - groupUid, - title, - "", - amount, - listOf(UserUidDto(payerUid)), - true, - null + groupUid = groupUid, + title = title, + description = "", + amount = amount, + paidBy = listOf(UserUidDto(payerUid)), + isSplitBetweenAll = true, + splitBetween = emptyList() ) expenseRepository.createExpense( diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorIntent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorIntent.kt index b557eb1..8248fab 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorIntent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorIntent.kt @@ -10,7 +10,13 @@ sealed class ExpenseEditorIntent( data object OnBackClick : ExpenseEditorIntent() data object OnDoneClick : ExpenseEditorIntent() data object OnCloseErrorClick : ExpenseEditorIntent() - data class OnPayerChanged(val payer: String) : ExpenseEditorIntent(isImmediate = true) - data class OnTitleChanged(val title: String) : ExpenseEditorIntent(isImmediate = true) - data class OnAmountChanged(val amount: String) : ExpenseEditorIntent(isImmediate = true) + data class OnPayerChanged( + val payer: String + ) : ExpenseEditorIntent(isImmediate = true) + data class OnTitleChanged( + val title: String + ) : ExpenseEditorIntent(isImmediate = true) + data class OnAmountChanged( + val amount: String + ) : ExpenseEditorIntent(isImmediate = true) } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/cells/model/ExpenseCellEvent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/cells/model/ExpenseCellEvent.kt index c2f7817..4c7faa5 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/cells/model/ExpenseCellEvent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/cells/model/ExpenseCellEvent.kt @@ -3,6 +3,10 @@ package com.github.ai.simplesplit.android.presentation.screens.groupDetails.cell import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellEvent sealed interface ExpenseCellEvent : CellEvent { - data class OnClick(val cellId: String) : ExpenseCellEvent - data class OnLongClick(val cellId: String) : ExpenseCellEvent + data class OnClick( + val cellId: String + ) : ExpenseCellEvent + data class OnLongClick( + val cellId: String + ) : ExpenseCellEvent } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/cells/model/SettlementCellEvent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/cells/model/SettlementCellEvent.kt index 5531139..a6a78a5 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/cells/model/SettlementCellEvent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/cells/model/SettlementCellEvent.kt @@ -3,5 +3,7 @@ package com.github.ai.simplesplit.android.presentation.screens.groupDetails.cell import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellEvent sealed interface SettlementCellEvent : CellEvent { - data class OnClick(val cellId: String) : SettlementCellEvent + data class OnClick( + val cellId: String + ) : SettlementCellEvent } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/model/GroupDetailsIntent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/model/GroupDetailsIntent.kt index 27a3db2..b4c1fe4 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/model/GroupDetailsIntent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/model/GroupDetailsIntent.kt @@ -15,11 +15,25 @@ sealed class GroupDetailsIntent( data object OnRemoveGroupClick : GroupDetailsIntent() data object OnRemoveGroupConfirmed : GroupDetailsIntent() data object OnCloseErrorClick : GroupDetailsIntent() - data class OnExpenseClick(val expenseUid: String) : GroupDetailsIntent() - data class OnExpenseLongClick(val expenseUid: String) : GroupDetailsIntent() - data class OnEditExpenseClick(val expenseUid: String) : GroupDetailsIntent() - data class OnRemoveExpenseClick(val expenseUid: String) : GroupDetailsIntent() - data class OnRemoveExpenseConfirmed(val expenseUid: String) : GroupDetailsIntent() - data class OpenUrl(val url: String) : GroupDetailsIntent() - data class ShareGroupUrl(val url: String) : GroupDetailsIntent() + data class OnExpenseClick( + val expenseUid: String + ) : GroupDetailsIntent() + data class OnExpenseLongClick( + val expenseUid: String + ) : GroupDetailsIntent() + data class OnEditExpenseClick( + val expenseUid: String + ) : GroupDetailsIntent() + data class OnRemoveExpenseClick( + val expenseUid: String + ) : GroupDetailsIntent() + data class OnRemoveExpenseConfirmed( + val expenseUid: String + ) : GroupDetailsIntent() + data class OpenUrl( + val url: String + ) : GroupDetailsIntent() + data class ShareGroupUrl( + val url: String + ) : GroupDetailsIntent() } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/model/GroupDetailsNavEvent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/model/GroupDetailsNavEvent.kt index 5b4d48a..20d631e 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/model/GroupDetailsNavEvent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/model/GroupDetailsNavEvent.kt @@ -1,6 +1,10 @@ package com.github.ai.simplesplit.android.presentation.screens.groupDetails.model sealed interface GroupDetailsNavEvent { - data class OpenUrl(val url: String) : GroupDetailsNavEvent - data class ShareUrl(val url: String) : GroupDetailsNavEvent + data class OpenUrl( + val url: String + ) : GroupDetailsNavEvent + data class ShareUrl( + val url: String + ) : GroupDetailsNavEvent } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorInteractor.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorInteractor.kt index 2f3c1c7..4517d55 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorInteractor.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorInteractor.kt @@ -11,11 +11,11 @@ import com.github.ai.simplesplit.android.data.repository.MemberRepository import com.github.ai.simplesplit.android.model.exception.AppException import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.GroupEditorData import com.github.ai.split.api.GroupDto +import com.github.ai.split.api.PostGroupRequest +import com.github.ai.split.api.PostMemberRequest +import com.github.ai.split.api.PutGroupRequest +import com.github.ai.split.api.PutMemberRequest import com.github.ai.split.api.UserNameDto -import com.github.ai.split.api.request.PostGroupRequest -import com.github.ai.split.api.request.PostMemberRequest -import com.github.ai.split.api.request.PutGroupRequest -import com.github.ai.split.api.request.PutMemberRequest typealias UserUidAndName = Pair @@ -112,12 +112,12 @@ class GroupEditorInteractor( ): Either = either { val request = PostGroupRequest( - password, - title, - null, - currencyIsoCode, - members.map { UserNameDto(it) }, - null + password = password, + title = title, + description = "", + currencyIsoCode = currencyIsoCode, + members = members.map { UserNameDto(it) }, + expenses = emptyList() ) val response = groupRepository.createGroup(request).bind() diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorIntent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorIntent.kt index 7d8d4e0..52646e2 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorIntent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorIntent.kt @@ -15,15 +15,31 @@ sealed class GroupEditorIntent( data object OnCancelMemberEditClick : GroupEditorIntent() data object OnApplyMemberEditClick : GroupEditorIntent() data object OnCurrencyClick : GroupEditorIntent() - data class OnTitleChanged(val title: String) : GroupEditorIntent(isImmediate = true) - data class OnPasswordChanged(val password: String) : GroupEditorIntent(isImmediate = true) + data class OnTitleChanged( + val title: String + ) : GroupEditorIntent(isImmediate = true) + data class OnPasswordChanged( + val password: String + ) : GroupEditorIntent(isImmediate = true) data class OnConfirmPasswordChanged( val confirmPassword: String ) : GroupEditorIntent(isImmediate = true) - data class OnMemberChanged(val member: String) : GroupEditorIntent(isImmediate = true) - data class OnRemoveMemberClick(val memberIndex: Int) : GroupEditorIntent() - data class OnEditMemberClick(val memberIndex: Int) : GroupEditorIntent() - data class OnPasswordToggleClick(val isVisible: Boolean) : GroupEditorIntent() - data class OnConfirmPasswordToggleClick(val isVisible: Boolean) : GroupEditorIntent() - data class OnCurrencySelected(val currency: CurrencyEntity) : GroupEditorIntent() + data class OnMemberChanged( + val member: String + ) : GroupEditorIntent(isImmediate = true) + data class OnRemoveMemberClick( + val memberIndex: Int + ) : GroupEditorIntent() + data class OnEditMemberClick( + val memberIndex: Int + ) : GroupEditorIntent() + data class OnPasswordToggleClick( + val isVisible: Boolean + ) : GroupEditorIntent() + data class OnConfirmPasswordToggleClick( + val isVisible: Boolean + ) : GroupEditorIntent() + data class OnCurrencySelected( + val currency: CurrencyEntity + ) : GroupEditorIntent() } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorMode.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorMode.kt index 9b12444..aacd831 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorMode.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorMode.kt @@ -6,7 +6,9 @@ import kotlinx.serialization.Serializable @Serializable sealed class GroupEditorMode { @Serializable - data class EditGroup(val credentials: GroupCredentials) : GroupEditorMode() + data class EditGroup( + val credentials: GroupCredentials + ) : GroupEditorMode() @Serializable data object NewGroup : GroupEditorMode() diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/cells/model/GroupCellModel.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/cells/model/GroupCellModel.kt index 56bd4b8..8876f31 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/cells/model/GroupCellModel.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/cells/model/GroupCellModel.kt @@ -14,6 +14,10 @@ data class GroupCellModel( ) : CellModel sealed interface GroupCellEvent : CellEvent { - data class OnClick(val cellId: String) : GroupCellEvent - data class OnLongClick(val cellId: String) : GroupCellEvent + data class OnClick( + val cellId: String + ) : GroupCellEvent + data class OnLongClick( + val cellId: String + ) : GroupCellEvent } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsIntent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsIntent.kt index 02729d6..c6df515 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsIntent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsIntent.kt @@ -13,12 +13,28 @@ sealed class GroupsIntent( data object OnAddGroupByUrlClick : GroupsIntent() data object OnSettingsClick : GroupsIntent() data object OnCloseErrorClick : GroupsIntent() - data class OnErrorActionClick(val actionId: Int) : GroupsIntent() - data class OnGroupClick(val groupUid: String) : GroupsIntent() - data class OnGroupLongClick(val groupUid: String) : GroupsIntent() - data class OnEditGroupClick(val groupUid: String) : GroupsIntent() - data class OnRemoveGroupClick(val groupUid: String) : GroupsIntent() - data class OnRemoveGroupConfirmed(val groupUid: String) : GroupsIntent() - data class OpenUrl(val url: String) : GroupsIntent() - data class ShareUrl(val url: String) : GroupsIntent() + data class OnErrorActionClick( + val actionId: Int + ) : GroupsIntent() + data class OnGroupClick( + val groupUid: String + ) : GroupsIntent() + data class OnGroupLongClick( + val groupUid: String + ) : GroupsIntent() + data class OnEditGroupClick( + val groupUid: String + ) : GroupsIntent() + data class OnRemoveGroupClick( + val groupUid: String + ) : GroupsIntent() + data class OnRemoveGroupConfirmed( + val groupUid: String + ) : GroupsIntent() + data class OpenUrl( + val url: String + ) : GroupsIntent() + data class ShareUrl( + val url: String + ) : GroupsIntent() } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/root/model/StartActivityEvent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/root/model/StartActivityEvent.kt index d8e33f6..51cd921 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/root/model/StartActivityEvent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/root/model/StartActivityEvent.kt @@ -1,6 +1,10 @@ package com.github.ai.simplesplit.android.presentation.screens.root.model sealed interface StartActivityEvent { - data class OpenUrl(val url: String) : StartActivityEvent - data class ShareUrl(val url: String) : StartActivityEvent + data class OpenUrl( + val url: String + ) : StartActivityEvent + data class ShareUrl( + val url: String + ) : StartActivityEvent } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/settings/model/SettingsState.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/settings/model/SettingsState.kt index 42389d5..4fb66c2 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/settings/model/SettingsState.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/settings/model/SettingsState.kt @@ -4,6 +4,10 @@ import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellVie sealed class SettingsState { data object Loading : SettingsState() - data class Error(val message: String) : SettingsState() - data class Data(val cellViewModels: List) : SettingsState() + data class Error( + val message: String + ) : SettingsState() + data class Data( + val cellViewModels: List + ) : SettingsState() } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/utils/CellId.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/utils/CellId.kt index 5956182..3c83481 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/utils/CellId.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/utils/CellId.kt @@ -9,8 +9,12 @@ data class CellId( ) sealed interface CellIdPayload { - data class IntPayload(val intValue: Int) : CellIdPayload - data class StringPayload(val text: String) : CellIdPayload + data class IntPayload( + val intValue: Int + ) : CellIdPayload + data class StringPayload( + val text: String + ) : CellIdPayload } fun CellIdPayload.getStringOrNull(): String? = (this as? StringPayload)?.text diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/utils/SingleFlow.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/utils/SingleFlow.kt index 10c14ee..56a6d2a 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/utils/SingleFlow.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/utils/SingleFlow.kt @@ -3,7 +3,9 @@ package com.github.ai.simplesplit.android.utils import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector -class SingleFlow(private val value: T) : Flow { +class SingleFlow( + private val value: T +) : Flow { override suspend fun collect(collector: FlowCollector) { collector.emit(value) diff --git a/android/backend-api/build.gradle.kts b/android/backend-api/build.gradle.kts new file mode 100644 index 0000000..c35229c --- /dev/null +++ b/android/backend-api/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.openapiGenerator) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation(libs.gson) +} + +openApiGenerate { + generatorName.set("kotlin") + inputSpec.set(rootProject.file("../backend/openapi-schema/openapi.yaml").absolutePath) + outputDir.set(layout.buildDirectory.dir("generated/openapi").get().asFile.absolutePath) + modelPackage.set("com.github.ai.split.api") + + globalProperties.set( + mapOf( + "models" to "", + "modelDocs" to "false", + "modelTests" to "false", + "apis" to "false", + "supportingFiles" to "false" + ) + ) + + configOptions.set( + mapOf( + "serializationLibrary" to "gson", + "dateLibrary" to "java8", + "modelMutable" to "false" + ) + ) +} + +sourceSets { + main { + kotlin.srcDir(layout.buildDirectory.dir("generated/openapi/src/main/kotlin")) + } +} + +tasks.named("compileKotlin") { + dependsOn(tasks.named("openApiGenerate")) +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 7d6d208..e4832b1 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,6 +1,16 @@ +plugins { + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlinAndroid) apply false + alias(libs.plugins.parcelize) apply false + alias(libs.plugins.kapt) apply false + alias(libs.plugins.kotlinSerialization) apply false + alias(libs.plugins.openapiGenerator) apply false +} + subprojects { repositories { google() mavenCentral() } -} \ No newline at end of file +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 8ff3b63..ad1915c 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -42,6 +42,8 @@ jgit = "6.2.0.202206071550-r" ksprefs = "2.4.1" constraintLayoutCompose = "1.0.1" gson = "2.13.1" +openapiGenerator = "7.14.0" +ktlintPlugin = "14.0.1" [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } @@ -53,6 +55,8 @@ kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } jpa = { id = "org.jetbrains.kotlin.plugin.jpa", version.ref = "kotlin" } +openapiGenerator = { id = "org.openapi.generator", version.ref = "openapiGenerator" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintPlugin" } [libraries] kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } @@ -121,4 +125,4 @@ mordant = { module = "com.github.ajalt.mordant:mordant", version.ref = "mordant" truth = { module = "com.google.truth:truth", version.ref = "truth" } jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version.ref = "jgit" } ksprefs = { module = "com.github.cioccarellia:ksprefs", version.ref = "ksprefs" } -gson = { module = "com.google.code.gson:gson", version.ref = "gson" } \ No newline at end of file +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index babc761..acefb0b 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -8,4 +8,5 @@ pluginManagement { rootProject.name = "simple-split-android" -include(":app") \ No newline at end of file +include(":app") +include(":backend-api") diff --git a/android/setup-api.sh b/android/setup-api.sh deleted file mode 100755 index be8a857..0000000 --- a/android/setup-api.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash - -JAR_NAME="simple-split-api.jar" -TARGET_DIR="api/target" - -# Function to find the api jar file -find_jar() { - find "$TARGET_DIR" -name "$JAR_NAME" -type f 2>/dev/null | head -1 -} - -set -e - -cd ../backend -sbt "project api" clean compile package -warn - -JAR_PATH=$(find_jar) - -if [[ ! -e "$JAR_PATH" ]]; then - echo "Unable to find $JAR_NAME file" - exit 1 -fi - -# Copy JAR to Android libs -mkdir -p ../android/app/libs -cp "$JAR_PATH" "../android/app/libs/$JAR_NAME" - -echo "$JAR_NAME built and copied successfully" diff --git a/backend/.env.debug b/backend/.env.debug deleted file mode 100644 index 83e7344..0000000 --- a/backend/.env.debug +++ /dev/null @@ -1,4 +0,0 @@ -POSTGRES_HOST=127.0.0.1 -POSTGRES_DB=simplesplit -POSTGRES_USER=changeme -POSTGRES_PASSWORD=changeme \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..e9d7ab6 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,17 @@ +# http | https +PROTOCOL=https + +# Path to SSL certificates, should be specified when running with https protocol +SERVER_CERTIFICATE_PATH=keys/server.crt +SERVER_PRIVATE_KEY_PATH=keys/server.key + +# jdbc:sqlite:./app-data/db/simple-split.sqlite?journal_mode=WAL&busy_timeout=30000 +# or +# jdbc:sqlite::memory: +DATABASE_URL=jdbc:sqlite:./app-data/db/simple-split.sqlite?journal_mode=WAL&busy_timeout=30000 +DATABASE_MAX_POOL_SIZE=4 +DATABASE_MIN_IDLE=1 + +# When true, populate the database with test data +# true | false +POPULATE_TEST_DATA=true \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index cbd7d68..459bbc3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,6 +6,6 @@ RUN sbt app/assembly FROM openjdk:17 EXPOSE 8080 8443 -RUN mkdir /app /data /app-data +RUN mkdir /app /data /app-data /app-data/db COPY --from=build /home/sbtuser/src/app/target/scala-*/simple-split-backend.jar /app/simplesplit-backend.jar -ENTRYPOINT ["java", "-jar", "/app/simplesplit-backend.jar", "--protocol", "http"] +ENTRYPOINT ["java", "-jar", "/app/simplesplit-backend.jar"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..30f8e69 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,22 @@ +# Backend + +### Generate openapi.yaml file +``` +sbt generateOpenApi +``` + +### Run backend app +Copy `.env.example` to `.env` and adjust the values, then run: +``` +sbt app/assembly +java -jar ./app/target/.../simple-split-backend.jar +``` + +The backend reads all startup configuration from `.env` (or from environment +variables with the same names). + +### OpenAPI UI +OpenAPI UI is available here (the backend/app should be running): +``` +https://127.0.0.1:8443/docs/openapi +``` diff --git a/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClient.scala b/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClient.scala index df98e39..5edbc92 100644 --- a/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClient.scala +++ b/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClient.scala @@ -2,11 +2,11 @@ package com.github.ai.split.client import com.github.ai.split.api.{NewExpenseDto, UserNameDto, UserUidDto} import com.github.ai.split.api.request.{PostExpenseRequest, PostGroupRequest, PostMemberRequest, PutMemberRequest} -import com.google.gson.GsonBuilder +import com.github.ai.split.openapi.Schemas.given import zio.* import zio.http.* - -import scala.jdk.CollectionConverters.* +import zio.schema.Schema +import zio.schema.codec.JsonCodec class ApiClient( private val client: Client @@ -14,7 +14,6 @@ class ApiClient( type ApiResponse = ZIO[Scope, Throwable, Response] - private val gson = GsonBuilder().setPrettyPrinting().create() private val DefaultPassword = "abc123" private val baseUrl = "https://127.0.0.1:8443" @@ -43,24 +42,24 @@ class ApiClient( "Oktoberfest", "Amazing party", "USD", - List("Bob", "Alan").map(UserNameDto(_)).asJava, + List("Bob", "Alan").map(UserNameDto(_)), List( NewExpenseDto( "Traditional Beer & Pretzels", "Authentic Bavarian beer and pretzels at Oktoberfest", 45.50, - List(UserNameDto("Bob")).asJava, - true, - List.empty.asJava + List(UserNameDto("Bob")), + Some(true), + List.empty ), // Option 2: Entry tickets NewExpenseDto( "Oktoberfest Entry Tickets", "Entry tickets for the beer festival", 24.00, - List(UserNameDto("Alan")).asJava, - true, - List.empty.asJava + List(UserNameDto("Alan")), + Some(true), + List.empty ), // Option 3: Traditional food @@ -68,17 +67,17 @@ class ApiClient( "Bratwurst and Sauerkraut", "Traditional Bavarian sausages and sauerkraut", 32.75, - List(UserNameDto("Bob")).asJava, - true, - List.empty.asJava + List(UserNameDto("Bob")), + Some(true), + List.empty ) - ).asJava + ) ) client.request( Request.post( path = s"$baseUrl/group", - body = Body.fromString(gson.toJson(body)) + body = Body.fromString(encode(body)) ) ) } @@ -92,15 +91,15 @@ class ApiClient( title, "", 18.0, - List(UserUidDto(Users.Mickey)).asJava, - true, - List.empty.asJava + List(UserUidDto(Users.Mickey)), + Some(true), + List.empty ) client.request( Request.post( path = s"$baseUrl/expense?password=$password", - body = Body.fromString(gson.toJson(body)) + body = Body.fromString(encode(body)) ) ) } @@ -118,7 +117,7 @@ class ApiClient( client.request( Request.post( path = s"$baseUrl/member?password=$password", - body = Body.fromString(gson.toJson(body)) + body = Body.fromString(encode(body)) ) ) } @@ -143,7 +142,7 @@ class ApiClient( Request.put( path = s"$baseUrl/member/$memberUid?password=$password", body = Body.fromString( - gson.toJson( + encode( PutMemberRequest(newName) ) ) @@ -151,6 +150,9 @@ class ApiClient( ) } + private def encode[A](value: A)(using schema: Schema[A]): String = + JsonCodec.jsonEncoder(schema).encodeJson(value, None).toString + def deleteExpense( expenseUid: String, password: String = DefaultPassword diff --git a/backend/api/src/main/java/com/github/ai/split/api/CurrencyDto.java b/backend/api/src/main/java/com/github/ai/split/api/CurrencyDto.java deleted file mode 100644 index a50db8c..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/CurrencyDto.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.github.ai.split.api; - -public class CurrencyDto { - - public String isoCode = ""; - public String name = ""; - public String symbol = ""; - - public CurrencyDto() {} - - public CurrencyDto(String isoCode, String name, String symbol) { - this.isoCode = isoCode; - this.name = name; - this.symbol = symbol; - } -} diff --git a/backend/api/src/main/java/com/github/ai/split/api/ErrorMessageDto.java b/backend/api/src/main/java/com/github/ai/split/api/ErrorMessageDto.java deleted file mode 100644 index e134fae..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/ErrorMessageDto.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.github.ai.split.api; - -import java.util.Collections; -import java.util.List; - -public class ErrorMessageDto { - - public String message = null; - public String exception = ""; - public String stacktraceBase64 = ""; - public List stacktraceLines = Collections.emptyList(); - - public ErrorMessageDto() { - } - - public ErrorMessageDto(String message, String exception, String stacktraceBase64, List stacktraceLines) { - this.message = message; - this.exception = exception; - this.stacktraceBase64 = stacktraceBase64; - this.stacktraceLines = stacktraceLines; - } -} diff --git a/backend/api/src/main/java/com/github/ai/split/api/ExpenseDto.java b/backend/api/src/main/java/com/github/ai/split/api/ExpenseDto.java deleted file mode 100644 index 7f0b838..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/ExpenseDto.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.github.ai.split.api; - -import java.util.Collections; -import java.util.List; -import java.util.ArrayList; - -public class ExpenseDto { - - public String uid = ""; - public String title = ""; - public String description = ""; - public double amount = 0.0; - public CurrencyDto currency = new CurrencyDto(); - public List paidBy = Collections.emptyList(); - public List splitBetween = Collections.emptyList(); - public TimestampDto created = new TimestampDto(); - public TimestampDto modified = new TimestampDto(); - - public ExpenseDto() { - this.paidBy = new ArrayList<>(); - this.splitBetween = new ArrayList<>(); - } - - public ExpenseDto(String uid, String title, String description, double amount, - CurrencyDto currency, List paidBy, List splitBetween, - TimestampDto created, TimestampDto modified) { - this.uid = uid; - this.title = title; - this.description = description; - this.amount = amount; - this.currency = currency != null ? currency : new CurrencyDto(); - this.paidBy = paidBy != null ? paidBy : new ArrayList<>(); - this.splitBetween = splitBetween != null ? splitBetween : new ArrayList<>(); - this.created = created != null ? created : new TimestampDto(); - this.modified = modified != null ? modified : new TimestampDto(); - } -} \ No newline at end of file diff --git a/backend/api/src/main/java/com/github/ai/split/api/GetGroupErrorDto.java b/backend/api/src/main/java/com/github/ai/split/api/GetGroupErrorDto.java deleted file mode 100644 index 055e677..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/GetGroupErrorDto.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.ai.split.api; - -public class GetGroupErrorDto { - - public String uid = ""; - public String message = ""; - - public GetGroupErrorDto() {} - - public GetGroupErrorDto(String uid, String message) { - this.uid = uid; - this.message = message; - } -} \ No newline at end of file diff --git a/backend/api/src/main/java/com/github/ai/split/api/GroupDto.java b/backend/api/src/main/java/com/github/ai/split/api/GroupDto.java deleted file mode 100644 index 32bad4d..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/GroupDto.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.github.ai.split.api; - -import java.util.Collections; -import java.util.List; -import java.util.ArrayList; - -public class GroupDto { - - public String uid = ""; - public String title = ""; - public String description = ""; - public CurrencyDto currency = new CurrencyDto(); - public List members = Collections.emptyList(); - public List expenses = Collections.emptyList(); - public List paybackTransactions = Collections.emptyList(); - public TimestampDto created = new TimestampDto(); - public TimestampDto modified = new TimestampDto(); - - public GroupDto() { - this.members = new ArrayList<>(); - this.expenses = new ArrayList<>(); - this.paybackTransactions = new ArrayList<>(); - } - - public GroupDto(String uid, String title, String description, CurrencyDto currency, - List members, List expenses, List paybackTransactions, - TimestampDto created, TimestampDto modified) { - this.uid = uid; - this.title = title; - this.description = description; - this.currency = currency != null ? currency : new CurrencyDto(); - this.members = members != null ? members : new ArrayList<>(); - this.expenses = expenses != null ? expenses : new ArrayList<>(); - this.paybackTransactions = paybackTransactions != null ? paybackTransactions : new ArrayList<>(); - this.created = created != null ? created : new TimestampDto(); - this.modified = modified != null ? modified : new TimestampDto(); - } -} \ No newline at end of file diff --git a/backend/api/src/main/java/com/github/ai/split/api/MemberDto.java b/backend/api/src/main/java/com/github/ai/split/api/MemberDto.java deleted file mode 100644 index d1577e5..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/MemberDto.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.ai.split.api; - -public class MemberDto { - - public String uid = ""; - public String name = ""; - - public MemberDto() {} - - public MemberDto(String uid, String name) { - this.uid = uid; - this.name = name; - } -} \ No newline at end of file diff --git a/backend/api/src/main/java/com/github/ai/split/api/NewExpenseDto.java b/backend/api/src/main/java/com/github/ai/split/api/NewExpenseDto.java deleted file mode 100644 index 8fbe159..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/NewExpenseDto.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.github.ai.split.api; - -import java.util.Collections; -import java.util.List; -import java.util.ArrayList; - -public class NewExpenseDto { - - public String title = ""; - public String description = ""; - public double amount = 0.0; - public List paidBy = Collections.emptyList(); - public Boolean isSplitBetweenAll = null; - public List splitBetween = Collections.emptyList(); - - public NewExpenseDto() { - this.paidBy = new ArrayList<>(); - this.splitBetween = new ArrayList<>(); - } - - public NewExpenseDto(String title, String description, double amount, - List paidBy, Boolean isSplitBetweenAll, - List splitBetween) { - this.title = title; - this.description = description; - this.amount = amount; - this.paidBy = paidBy != null ? paidBy : new ArrayList<>(); - this.isSplitBetweenAll = isSplitBetweenAll; - this.splitBetween = splitBetween != null ? splitBetween : new ArrayList<>(); - } -} - diff --git a/backend/api/src/main/java/com/github/ai/split/api/TimestampDto.java b/backend/api/src/main/java/com/github/ai/split/api/TimestampDto.java deleted file mode 100644 index 843e579..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/TimestampDto.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.ai.split.api; - -public class TimestampDto { - - public long timestampSeconds = 0L; - public String formatted = ""; - - public TimestampDto() {} - - public TimestampDto(long timestampSeconds, String formatted) { - this.timestampSeconds = timestampSeconds; - this.formatted = formatted; - } -} \ No newline at end of file diff --git a/backend/api/src/main/java/com/github/ai/split/api/TransactionDto.java b/backend/api/src/main/java/com/github/ai/split/api/TransactionDto.java deleted file mode 100644 index 76542db..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/TransactionDto.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.github.ai.split.api; - -public class TransactionDto { - - public String creditorUid = ""; - public String debtorUid = ""; - public double amount = 0.0; - - public TransactionDto() {} - - public TransactionDto(String creditorUid, String debtorUid, double amount) { - this.creditorUid = creditorUid; - this.debtorUid = debtorUid; - this.amount = amount; - } -} \ No newline at end of file diff --git a/backend/api/src/main/java/com/github/ai/split/api/UserNameDto.java b/backend/api/src/main/java/com/github/ai/split/api/UserNameDto.java deleted file mode 100644 index b101523..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/UserNameDto.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.github.ai.split.api; - -public class UserNameDto { - - public String name = ""; - - public UserNameDto() {} - - public UserNameDto(String name) { - this.name = name; - } -} - diff --git a/backend/api/src/main/java/com/github/ai/split/api/UserUidDto.java b/backend/api/src/main/java/com/github/ai/split/api/UserUidDto.java deleted file mode 100644 index 2678eb1..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/UserUidDto.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.github.ai.split.api; - -public class UserUidDto { - - public String uid = ""; - - public UserUidDto() {} - - public UserUidDto(String uid) { - this.uid = uid; - } -} diff --git a/backend/api/src/main/java/com/github/ai/split/api/request/PostExpenseRequest.java b/backend/api/src/main/java/com/github/ai/split/api/request/PostExpenseRequest.java deleted file mode 100644 index bdc0a7a..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/request/PostExpenseRequest.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.github.ai.split.api.request; - -import com.github.ai.split.api.UserUidDto; -import java.util.Collections; -import java.util.List; -import java.util.ArrayList; - -public class PostExpenseRequest { - - public String groupUid = ""; - public String title = ""; - public String description = ""; - public double amount = 0.0; - public List paidBy = Collections.emptyList(); - public Boolean isSplitBetweenAll = null; - public List splitBetween = Collections.emptyList(); - - public PostExpenseRequest() { - this.paidBy = new ArrayList<>(); - this.splitBetween = new ArrayList<>(); - } - - public PostExpenseRequest(String groupUid, String title, String description, double amount, - List paidBy, Boolean isSplitBetweenAll, - List splitBetween) { - this.groupUid = groupUid; - this.title = title; - this.description = description; - this.amount = amount; - this.paidBy = paidBy != null ? paidBy : new ArrayList<>(); - this.isSplitBetweenAll = isSplitBetweenAll; - this.splitBetween = splitBetween != null ? splitBetween : new ArrayList<>(); - } -} - diff --git a/backend/api/src/main/java/com/github/ai/split/api/request/PostGroupRequest.java b/backend/api/src/main/java/com/github/ai/split/api/request/PostGroupRequest.java deleted file mode 100644 index f359934..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/request/PostGroupRequest.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.github.ai.split.api.request; - -import com.github.ai.split.api.NewExpenseDto; -import com.github.ai.split.api.UserNameDto; -import java.util.Collections; -import java.util.List; -import java.util.ArrayList; - -public class PostGroupRequest { - - public String password = ""; - public String title = ""; - public String description = ""; - public String currencyIsoCode = ""; - public List members = Collections.emptyList(); - public List expenses = Collections.emptyList(); - - public PostGroupRequest() { - this.members = new ArrayList<>(); - this.expenses = new ArrayList<>(); - } - - public PostGroupRequest(String password, String title, String description, String currencyIsoCode, - List members, List expenses) { - this.password = password; - this.title = title; - this.description = description; - this.currencyIsoCode = currencyIsoCode; - this.members = members != null ? members : new ArrayList<>(); - this.expenses = expenses != null ? expenses : new ArrayList<>(); - } -} - diff --git a/backend/api/src/main/java/com/github/ai/split/api/request/PostMemberRequest.java b/backend/api/src/main/java/com/github/ai/split/api/request/PostMemberRequest.java deleted file mode 100644 index d00640f..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/request/PostMemberRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.github.ai.split.api.request; - -public class PostMemberRequest { - - public String groupUid = ""; - public String name = ""; - - public PostMemberRequest() { - } - - public PostMemberRequest(String groupUid, String name) { - this.groupUid = groupUid; - this.name = name; - } -} diff --git a/backend/api/src/main/java/com/github/ai/split/api/request/PutExpenseRequest.java b/backend/api/src/main/java/com/github/ai/split/api/request/PutExpenseRequest.java deleted file mode 100644 index 4826e3c..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/request/PutExpenseRequest.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.github.ai.split.api.request; - -import com.github.ai.split.api.UserUidDto; -import java.util.Collections; -import java.util.List; -import java.util.ArrayList; - -public class PutExpenseRequest { - - public String title = ""; - public String description = ""; - public Double amount = null; - public List paidBy = Collections.emptyList(); - public Boolean isSplitBetweenAll = null; - public List splitBetween = Collections.emptyList(); - - public PutExpenseRequest() { - this.paidBy = new ArrayList<>(); - this.splitBetween = new ArrayList<>(); - } - - public PutExpenseRequest(String title, String description, Double amount, - List paidBy, Boolean isSplitBetweenAll, - List splitBetween) { - this.title = title; - this.description = description; - this.amount = amount; - this.paidBy = paidBy != null ? paidBy : new ArrayList<>(); - this.isSplitBetweenAll = isSplitBetweenAll; - this.splitBetween = splitBetween != null ? splitBetween : new ArrayList<>(); - } -} - diff --git a/backend/api/src/main/java/com/github/ai/split/api/request/PutGroupRequest.java b/backend/api/src/main/java/com/github/ai/split/api/request/PutGroupRequest.java deleted file mode 100644 index 179d3b5..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/request/PutGroupRequest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.github.ai.split.api.request; - -import com.github.ai.split.api.UserUidDto; -import java.util.Collections; -import java.util.List; -import java.util.ArrayList; - -public class PutGroupRequest { - - public String title = null; - public String password = null; - public String description = null; - public String currencyIsoCode = null; - public List members = Collections.emptyList(); - - public PutGroupRequest() { - this.members = new ArrayList<>(); - } - - public PutGroupRequest(String title, String password, String description, String currencyIsoCode, - List members) { - this.title = title; - this.password = password; - this.description = description; - this.currencyIsoCode = currencyIsoCode; - this.members = members != null ? members : new ArrayList<>(); - } -} - diff --git a/backend/api/src/main/java/com/github/ai/split/api/request/PutMemberRequest.java b/backend/api/src/main/java/com/github/ai/split/api/request/PutMemberRequest.java deleted file mode 100644 index 5d0306f..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/request/PutMemberRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.github.ai.split.api.request; - -public class PutMemberRequest { - - public String name = ""; - - public PutMemberRequest() {} - - public PutMemberRequest(String name) { - this.name = name; - } -} \ No newline at end of file diff --git a/backend/api/src/main/java/com/github/ai/split/api/response/DeleteExpenseResponse.java b/backend/api/src/main/java/com/github/ai/split/api/response/DeleteExpenseResponse.java deleted file mode 100644 index 1e5ed0d..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/response/DeleteExpenseResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.github.ai.split.api.response; - -import com.github.ai.split.api.GroupDto; -public class DeleteExpenseResponse { - - public GroupDto group = new GroupDto(); - - public DeleteExpenseResponse() {} - - public DeleteExpenseResponse(GroupDto group) { - this.group = group != null ? group : new GroupDto(); - } -} \ No newline at end of file diff --git a/backend/api/src/main/java/com/github/ai/split/api/response/DeleteMemberResponse.java b/backend/api/src/main/java/com/github/ai/split/api/response/DeleteMemberResponse.java deleted file mode 100644 index 7827268..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/response/DeleteMemberResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.github.ai.split.api.response; - -import com.github.ai.split.api.GroupDto; -public class DeleteMemberResponse { - - public GroupDto group = new GroupDto(); - - public DeleteMemberResponse() {} - - public DeleteMemberResponse(GroupDto group) { - this.group = group != null ? group : new GroupDto(); - } -} \ No newline at end of file diff --git a/backend/api/src/main/java/com/github/ai/split/api/response/GetCurrenciesResponse.java b/backend/api/src/main/java/com/github/ai/split/api/response/GetCurrenciesResponse.java deleted file mode 100644 index a01c855..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/response/GetCurrenciesResponse.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.github.ai.split.api.response; - -import com.github.ai.split.api.CurrencyDto; - -import java.util.Collections; -import java.util.List; -import java.util.ArrayList; - - -public class GetCurrenciesResponse { - - public List currencies = Collections.emptyList(); - - public GetCurrenciesResponse() { - this.currencies = new ArrayList<>(); - } - - public GetCurrenciesResponse(List currencies) { - this.currencies = currencies != null ? currencies : new ArrayList<>(); - } -} \ No newline at end of file diff --git a/backend/api/src/main/java/com/github/ai/split/api/response/GetGroupsResponse.java b/backend/api/src/main/java/com/github/ai/split/api/response/GetGroupsResponse.java deleted file mode 100644 index 4120c23..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/response/GetGroupsResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.github.ai.split.api.response; - -import com.github.ai.split.api.GroupDto; -import com.github.ai.split.api.GetGroupErrorDto; -import java.util.Collections; -import java.util.List; -import java.util.ArrayList; - -public class GetGroupsResponse { - - public List groups = Collections.emptyList(); - public List errors = Collections.emptyList(); - - public GetGroupsResponse() { - this.groups = new ArrayList<>(); - this.errors = new ArrayList<>(); - } - - public GetGroupsResponse(List groups, List errors) { - this.groups = groups != null ? groups : new ArrayList<>(); - this.errors = errors != null ? errors : new ArrayList<>(); - } -} \ No newline at end of file diff --git a/backend/api/src/main/java/com/github/ai/split/api/response/PostExpenseResponse.java b/backend/api/src/main/java/com/github/ai/split/api/response/PostExpenseResponse.java deleted file mode 100644 index b3bb400..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/response/PostExpenseResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.ai.split.api.response; - -import com.github.ai.split.api.ExpenseDto; - -public class PostExpenseResponse { - - public ExpenseDto expense = new ExpenseDto(); - - public PostExpenseResponse() {} - - public PostExpenseResponse(ExpenseDto expense) { - this.expense = expense != null ? expense : new ExpenseDto(); - } -} \ No newline at end of file diff --git a/backend/api/src/main/java/com/github/ai/split/api/response/PostGroupResponse.java b/backend/api/src/main/java/com/github/ai/split/api/response/PostGroupResponse.java deleted file mode 100644 index 0dacfdd..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/response/PostGroupResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.github.ai.split.api.response; - -import com.github.ai.split.api.GroupDto; -public class PostGroupResponse { - - public GroupDto group = new GroupDto(); - - public PostGroupResponse() {} - - public PostGroupResponse(GroupDto group) { - this.group = group != null ? group : new GroupDto(); - } -} \ No newline at end of file diff --git a/backend/api/src/main/java/com/github/ai/split/api/response/PostMemberResponse.java b/backend/api/src/main/java/com/github/ai/split/api/response/PostMemberResponse.java deleted file mode 100644 index f336bef..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/response/PostMemberResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.ai.split.api.response; - -import com.github.ai.split.api.GroupDto; -public class PostMemberResponse { - - public GroupDto group = new GroupDto(); - - public PostMemberResponse() {} - - public PostMemberResponse(GroupDto group) { - this.group = group != null ? group : new GroupDto(); - } -} - diff --git a/backend/api/src/main/java/com/github/ai/split/api/response/PutExpenseResponse.java b/backend/api/src/main/java/com/github/ai/split/api/response/PutExpenseResponse.java deleted file mode 100644 index 1113525..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/response/PutExpenseResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.github.ai.split.api.response; - -import com.github.ai.split.api.ExpenseDto; -public class PutExpenseResponse { - - public ExpenseDto expense = new ExpenseDto(); - - public PutExpenseResponse() {} - - public PutExpenseResponse(ExpenseDto expense) { - this.expense = expense != null ? expense : new ExpenseDto(); - } -} \ No newline at end of file diff --git a/backend/api/src/main/java/com/github/ai/split/api/response/PutGroupResponse.java b/backend/api/src/main/java/com/github/ai/split/api/response/PutGroupResponse.java deleted file mode 100644 index bc078cf..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/response/PutGroupResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.github.ai.split.api.response; - -import com.github.ai.split.api.GroupDto; -public class PutGroupResponse { - - public GroupDto group = new GroupDto(); - - public PutGroupResponse() {} - - public PutGroupResponse(GroupDto group) { - this.group = group != null ? group : new GroupDto(); - } -} \ No newline at end of file diff --git a/backend/api/src/main/java/com/github/ai/split/api/response/PutMemberResponse.java b/backend/api/src/main/java/com/github/ai/split/api/response/PutMemberResponse.java deleted file mode 100644 index 807507f..0000000 --- a/backend/api/src/main/java/com/github/ai/split/api/response/PutMemberResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.github.ai.split.api.response; - -import com.github.ai.split.api.GroupDto; -public class PutMemberResponse { - - public GroupDto group = new GroupDto(); - - public PutMemberResponse() {} - - public PutMemberResponse(GroupDto group) { - this.group = group != null ? group : new GroupDto(); - } -} \ No newline at end of file diff --git a/backend/api/src/main/scala/com/github/ai/split/api/Dto.scala b/backend/api/src/main/scala/com/github/ai/split/api/Dto.scala new file mode 100644 index 0000000..50dd181 --- /dev/null +++ b/backend/api/src/main/scala/com/github/ai/split/api/Dto.scala @@ -0,0 +1,72 @@ +package com.github.ai.split.api + +final case class CurrencyDto( + isoCode: String, + name: String, + symbol: String +) + +final case class TimestampDto( + timestampSeconds: Long, + formatted: String +) + +final case class MemberDto( + uid: String, + name: String +) + +final case class UserNameDto(name: String) + +final case class UserUidDto(uid: String) + +final case class TransactionDto( + creditorUid: String, + debtorUid: String, + amount: Double +) + +final case class ExpenseDto( + uid: String, + title: String, + description: String, + amount: Double, + currency: CurrencyDto, + paidBy: List[MemberDto], + splitBetween: List[MemberDto], + created: TimestampDto, + modified: TimestampDto +) + +final case class NewExpenseDto( + title: String, + description: String, + amount: Double, + paidBy: List[UserNameDto], + isSplitBetweenAll: Option[Boolean], + splitBetween: List[UserNameDto] +) + +final case class GroupDto( + uid: String, + title: String, + description: String, + currency: CurrencyDto, + members: List[MemberDto], + expenses: List[ExpenseDto], + paybackTransactions: List[TransactionDto], + created: TimestampDto, + modified: TimestampDto +) + +final case class GetGroupErrorDto( + uid: String, + message: String +) + +final case class ErrorMessageDto( + message: Option[String], + exception: String, + stacktraceBase64: String, + stacktraceLines: List[String] +) diff --git a/backend/api/src/main/scala/com/github/ai/split/api/request/Requests.scala b/backend/api/src/main/scala/com/github/ai/split/api/request/Requests.scala new file mode 100644 index 0000000..f221dfd --- /dev/null +++ b/backend/api/src/main/scala/com/github/ai/split/api/request/Requests.scala @@ -0,0 +1,46 @@ +package com.github.ai.split.api.request + +import com.github.ai.split.api.{NewExpenseDto, UserNameDto, UserUidDto} + +final case class PostGroupRequest( + password: String, + title: String, + description: String, + currencyIsoCode: String, + members: List[UserNameDto], + expenses: List[NewExpenseDto] +) + +final case class PutGroupRequest( + title: Option[String] = None, + password: Option[String] = None, + description: Option[String] = None, + currencyIsoCode: Option[String] = None, + members: List[UserUidDto] = Nil +) + +final case class PostMemberRequest( + groupUid: String, + name: String +) + +final case class PutMemberRequest(name: String) + +final case class PostExpenseRequest( + groupUid: String, + title: String, + description: String, + amount: Double, + paidBy: List[UserUidDto], + isSplitBetweenAll: Option[Boolean], + splitBetween: List[UserUidDto] +) + +final case class PutExpenseRequest( + title: Option[String] = None, + description: Option[String] = None, + amount: Option[Double] = None, + paidBy: List[UserUidDto] = Nil, + isSplitBetweenAll: Option[Boolean] = None, + splitBetween: List[UserUidDto] = Nil +) diff --git a/backend/api/src/main/scala/com/github/ai/split/api/response/Responses.scala b/backend/api/src/main/scala/com/github/ai/split/api/response/Responses.scala new file mode 100644 index 0000000..3a545c5 --- /dev/null +++ b/backend/api/src/main/scala/com/github/ai/split/api/response/Responses.scala @@ -0,0 +1,19 @@ +package com.github.ai.split.api.response + +import com.github.ai.split.api.{CurrencyDto, ExpenseDto, GetGroupErrorDto, GroupDto} + +final case class GetCurrenciesResponse(currencies: List[CurrencyDto]) + +final case class GetGroupsResponse( + groups: List[GroupDto], + errors: List[GetGroupErrorDto] +) + +final case class PostGroupResponse(group: GroupDto) +final case class PutGroupResponse(group: GroupDto) +final case class PostMemberResponse(group: GroupDto) +final case class PutMemberResponse(group: GroupDto) +final case class DeleteMemberResponse(group: GroupDto) +final case class PostExpenseResponse(expense: ExpenseDto) +final case class PutExpenseResponse(expense: ExpenseDto) +final case class DeleteExpenseResponse(group: GroupDto) diff --git a/backend/app/src/main/resources/logback.xml b/backend/app/src/main/resources/logback.xml index 5256d16..8aad8f9 100644 --- a/backend/app/src/main/resources/logback.xml +++ b/backend/app/src/main/resources/logback.xml @@ -3,24 +3,24 @@ - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + %msg%n app-data/logs/simplesplit.log true - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + %msg%n - - + + - - + + - \ No newline at end of file + diff --git a/backend/app/src/main/scala/com/github/ai/split/Layers.scala b/backend/app/src/main/scala/com/github/ai/split/Layers.scala index cf7015f..725fb16 100644 --- a/backend/app/src/main/scala/com/github/ai/split/Layers.scala +++ b/backend/app/src/main/scala/com/github/ai/split/Layers.scala @@ -1,6 +1,5 @@ package com.github.ai.split -import com.github.ai.split.data.JsonSerializer import com.github.ai.split.data.currency.CurrencyParser import com.github.ai.split.data.db.{AppDatabase, DatabaseConnectionFactory} import com.github.ai.split.data.db.dao.{ @@ -40,6 +39,7 @@ import com.github.ai.split.domain.usecases.{ ValidateExpenseUseCase, ValidateMemberNameUseCase } +import com.github.ai.split.entity.ApplicationConfig import com.github.ai.split.presentation.controllers.{ CurrencyController, ExpenseController, @@ -47,14 +47,17 @@ import com.github.ai.split.presentation.controllers.{ MemberController } import zio.{ZIO, ZLayer} +import zio.direct.* object Layers { // Database - val appDatabase = ZLayer.fromZIO { - for { - db <- DatabaseConnectionFactory().create() - } yield AppDatabase(db) + val appDatabase = ZLayer.scoped { + defer { + val config = ZIO.service[ApplicationConfig].run + val db = DatabaseConnectionFactory(config.database).create().run + AppDatabase(db) + } } // Dao's @@ -104,12 +107,11 @@ object Layers { val assembleExpenseUseCase = ZLayer.fromFunction(AssembleExpenseUseCase(_, _, _, _)) // Controllers - val groupController = ZLayer.fromFunction(GroupController(_, _, _, _, _, _, _, _, _, _, _)) - val memberController = ZLayer.fromFunction(MemberController(_, _, _, _, _, _, _, _, _)) - val expenseController = ZLayer.fromFunction(ExpenseController(_, _, _, _, _, _, _, _)) - val currencyController = ZLayer.fromFunction(CurrencyController(_, _)) + val groupController = ZLayer.fromFunction(GroupController(_, _, _, _, _, _, _, _, _, _)) + val memberController = ZLayer.fromFunction(MemberController(_, _, _, _, _, _, _, _)) + val expenseController = ZLayer.fromFunction(ExpenseController(_, _, _, _, _, _, _)) + val currencyController = ZLayer.fromFunction(CurrencyController(_)) // Other val currencyParser = ZLayer.succeed(CurrencyParser()) - val jsonSerialized = ZLayer.succeed(JsonSerializer()) } diff --git a/backend/app/src/main/scala/com/github/ai/split/Main.scala b/backend/app/src/main/scala/com/github/ai/split/Main.scala index 6e37c0c..f122c4f 100644 --- a/backend/app/src/main/scala/com/github/ai/split/Main.scala +++ b/backend/app/src/main/scala/com/github/ai/split/Main.scala @@ -2,27 +2,45 @@ package com.github.ai.split import com.github.ai.split.data.currency.CurrencyParser import com.github.ai.split.data.db.AppDatabase -import com.github.ai.split.domain.CliArgumentParser +import com.github.ai.split.domain.ApplicationConfigLoader import com.github.ai.split.domain.usecases.{FillTestDataUseCase, StartUpServerUseCase} -import com.github.ai.split.entity.CliArguments +import com.github.ai.split.entity.ApplicationConfig import com.github.ai.split.entity.HttpProtocol.{HTTP, HTTPS} import com.github.ai.split.presentation.routes.{CurrencyRoutes, ExpenseRoutes, ExportRoutes, GroupRoutes, MemberRoutes} +import com.github.ai.split.openapi.ApiEndpoints +import com.github.ai.split.utils.RequestLogger import zio.* import zio.http.* -import zio.logging.LogFormat +import zio.logging.{LogColor, LogFormat, LoggerNameExtractor} import zio.logging.backend.SLF4J import zio.direct.* +import zio.http.endpoint.openapi.SwaggerUI +import zio.http.codec.PathCodec.path + +import java.time.format.DateTimeFormatter object Main extends ZIOAppDefault { - private val routes = GroupRoutes.routes() - ++ ExportRoutes.routes() - ++ MemberRoutes.routes() - ++ ExpenseRoutes.routes() - ++ CurrencyRoutes.routes() + private val routes = + (GroupRoutes.routes() + ++ ExportRoutes.routes() + ++ MemberRoutes.routes() + ++ ExpenseRoutes.routes() + ++ CurrencyRoutes.routes() + ++ SwaggerUI.routes("docs" / "openapi", ApiEndpoints.openApi)) + @@ RequestLogger.requestLogger override val bootstrap: ZLayer[Any, Nothing, Unit] = { - Runtime.removeDefaultLoggers >>> SLF4J.slf4j(LogFormat.colored) + val logFormat: LogFormat = + LogFormat + .timestamp(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssAZ")) + .highlight(_ => LogColor.BLUE) + |-| LogFormat.bracketStart + LogFormat.loggerName( + LoggerNameExtractor.trace + ) + LogFormat.bracketEnd |-| + LogFormat.fiberId |-| LogFormat.level.highlight |-| LogFormat.line.highlight + + Runtime.removeDefaultLoggers >>> SLF4J.slf4j(logFormat) } private def application() = defer { @@ -35,33 +53,40 @@ object Main extends ZIOAppDefault { } private def createServerConfig( - arguments: CliArguments + config: ApplicationConfig ) = defer { - arguments.protocol match { + config.server.protocol match { case HTTP => Server.Config.default - .port(arguments.getPort()) + .port(8080) case HTTPS => Server.Config.default - .port(arguments.getPort()) - .ssl(SSLConfig.fromFile("dev-data/server.crt", "dev-data/server.key")) + .port(8443) + .ssl(SSLConfig.fromFile(config.server.certificatePath, config.server.privateKeyPath)) } } - override def run: ZIO[ZIOAppArgs, Throwable, Unit] = { - for { - arguments <- CliArgumentParser().parse() - _ <- ZIO.logInfo(s"Starting server on port ${arguments.getPort()}") - _ <- ZIO.logInfo(s" isUseInMemoryDatabase=${arguments.isUseInMemoryDatabase}") - _ <- ZIO.logInfo(s" isPopulateTestData=${arguments.isPopulateTestData}") - _ <- ZIO.logInfo(s" protocol=${arguments.protocol}") + override def run: ZIO[ZIOAppArgs, Throwable, Unit] = defer { + val config = ApplicationConfigLoader().loadConfig().run + val port = config.server.protocol match { + case HTTP => 8080 + case HTTPS => 8443 + } + + ZIO.logInfo(s"Starting server on port $port").run + ZIO.logInfo(s" database.url=${config.database.url}").run + ZIO.logInfo(s" database.maximumPoolSize=${config.database.maximumPoolSize}").run + ZIO.logInfo(s" database.minimumIdle=${config.database.minimumIdle}").run + ZIO.logInfo(s" populateTestData=${config.populateTestData}").run + ZIO.logInfo(s" protocol=${config.server.protocol}").run - serverConfig <- createServerConfig(arguments) + val serverConfig = createServerConfig(config).run - _ <- application().provide( - // Application arguments - ZLayer.succeed(arguments), + application() + .provide( + // Application config + ZLayer.succeed(config), // Use-Cases Layers.addUserUseCase, @@ -120,10 +145,10 @@ object Main extends ZIOAppDefault { // Others Layers.currencyParser, - Layers.jsonSerialized, Server.live, ZLayer.succeed(serverConfig) ) - } yield () + .run + () } } diff --git a/backend/app/src/main/scala/com/github/ai/split/data/JsonSerializer.scala b/backend/app/src/main/scala/com/github/ai/split/data/JsonSerializer.scala deleted file mode 100644 index c77215e..0000000 --- a/backend/app/src/main/scala/com/github/ai/split/data/JsonSerializer.scala +++ /dev/null @@ -1,30 +0,0 @@ -package com.github.ai.split.data - -import com.github.ai.split.entity.exception.{DomainError, JsonDeserializationError, ParsingError} -import com.github.ai.split.utils.some -import com.google.gson.GsonBuilder -import com.google.gson.reflect.TypeToken -import zio.{IO, Task, ZIO} - -class JsonSerializer { - - private val gson = GsonBuilder().setPrettyPrinting().create() - - def serialize(data: Any): String = { - gson.toJson(data) - } - - def deserializer[T]( - data: Task[String], - typeOf: Class[T] - ): IO[ParsingError, T] = { - for { - json <- data.mapError(error => ParsingError(message = "Unable to read json data")) - result <- ZIO - .attempt { - gson.fromJson(json, TypeToken.get(typeOf)) - } - .mapError(error => JsonDeserializationError(typeOf = typeOf, cause = error.some)) - } yield result - } -} diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/AppDatabase.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/AppDatabase.scala index 3eb8f84..ad50f5e 100644 --- a/backend/app/src/main/scala/com/github/ai/split/data/db/AppDatabase.scala +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/AppDatabase.scala @@ -1,6 +1,5 @@ package com.github.ai.split.data.db -import com.github.ai.split.data.db.model.DatabaseConnection import com.github.ai.split.entity.db.{ CurrencyEntity, ExpenseEntity, @@ -16,16 +15,16 @@ import com.github.ai.split.entity.db.{ UserUid } import com.github.ai.split.entity.exception.DomainError -import com.github.ai.split.utils.{toDomainError, toProperties} -import slick.jdbc.PostgresProfile.api.* +import com.github.ai.split.utils.toDomainError +import slick.jdbc.SQLiteProfile.api.* import slick.lifted.ProvenShape import zio.{IO, ZIO} import zio.direct.* -import java.util.{Properties, UUID} +import java.util.UUID class AppDatabase( - private val connection: DatabaseConnection + val context: Database ) { val CurrencyTable = TableQuery[CurrencyEntityTable] @@ -36,15 +35,6 @@ class AppDatabase( val ExpenseTable = TableQuery[ExpenseEntityTable] val GroupTable = TableQuery[GroupEntityTable] - val context = Database.forURL( - url = connection.url, - user = connection.user, - password = connection.password, - driver = "org.postgresql.Driver", - keepAliveConnection = true, - prop = Map(("connectionPool", "HikariCP")).toProperties() - ) - def initialize(): IO[DomainError, Unit] = defer { ZIO @@ -134,24 +124,24 @@ class GroupEntityTable(tag: Tag) extends Table[GroupEntity](tag, None, "GroupEnt (uid, title, description, passwordHash, currencyIsoCode, created, modified).mapTo[GroupEntity] } -given userUidColumnType: BaseColumnType[UserUid] = MappedColumnType.base[UserUid, UUID]( - uid => uid.value, // UserUid to UUID - uuid => UserUid(uuid) // UUID to UserUid +given userUidColumnType: BaseColumnType[UserUid] = MappedColumnType.base[UserUid, String]( + uid => uid.value.toString, + value => UserUid(UUID.fromString(value)) ) -given groupUidColumnType: BaseColumnType[GroupUid] = MappedColumnType.base[GroupUid, UUID]( - uid => uid.value, // GroupUid to UUID - uuid => GroupUid(uuid) // UUID to GroupUid +given groupUidColumnType: BaseColumnType[GroupUid] = MappedColumnType.base[GroupUid, String]( + uid => uid.value.toString, + value => GroupUid(UUID.fromString(value)) ) -given memberUidColumnType: BaseColumnType[MemberUid] = MappedColumnType.base[MemberUid, UUID]( - uid => uid.value, // MemberUid to UUID - uuid => MemberUid(uuid) // UUID to MemberUid +given memberUidColumnType: BaseColumnType[MemberUid] = MappedColumnType.base[MemberUid, String]( + uid => uid.value.toString, + value => MemberUid(UUID.fromString(value)) ) -given expenseUidColumnType: BaseColumnType[ExpenseUid] = MappedColumnType.base[ExpenseUid, UUID]( - uid => uid.value, // ExpenseUid to UUID - uuid => ExpenseUid(uuid) // UUID to ExpenseUid +given expenseUidColumnType: BaseColumnType[ExpenseUid] = MappedColumnType.base[ExpenseUid, String]( + uid => uid.value.toString, + value => ExpenseUid(UUID.fromString(value)) ) given timestampColumnType: BaseColumnType[Timestamp] = MappedColumnType.base[Timestamp, Long]( diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/DatabaseConfig.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/DatabaseConfig.scala new file mode 100644 index 0000000..f2aecdd --- /dev/null +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/DatabaseConfig.scala @@ -0,0 +1,7 @@ +package com.github.ai.split.data.db + +case class DatabaseConfig( + url: String, + maximumPoolSize: Int, + minimumIdle: Int +) diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/DatabaseConnectionFactory.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/DatabaseConnectionFactory.scala index 9d34678..9003ffc 100644 --- a/backend/app/src/main/scala/com/github/ai/split/data/db/DatabaseConnectionFactory.scala +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/DatabaseConnectionFactory.scala @@ -1,111 +1,38 @@ package com.github.ai.split.data.db -import com.github.ai.split.data.db.DatabaseConnectionFactory.{ - POSTGRES_DB, - POSTGRES_HOST, - POSTGRES_PASSWORD, - POSTGRES_USER -} -import com.github.ai.split.data.db.model.DatabaseConnection import com.github.ai.split.entity.exception.DomainError -import com.github.ai.split.utils.some -import zio.direct.{defer, run} -import zio.{IO, ZIO} - -import java.io.File -import scala.io.Source - -class DatabaseConnectionFactory { - - def create(): IO[DomainError, DatabaseConnection] = { - defer { - val host = sys.env.getOrElse(POSTGRES_HOST, "") - val db = sys.env.getOrElse(POSTGRES_DB, "") - val user = sys.env.getOrElse(POSTGRES_USER, "") - val password = sys.env.getOrElse(POSTGRES_PASSWORD, "") - - val envFileContent = readEnvironmentFile() - .flatMap { content => parseEnvironmentFile(content) } - - if (host.nonEmpty && db.nonEmpty && user.nonEmpty) { - newConnectionFrom( - host = host, - database = db, - user = user, - password = password - ) - } else if (envFileContent.isDefined) { - envFileContent.get - } else { - ZIO.fail(DomainError(message = "Database connection is not specified".some)).run - } - } - } - - private def readEnvironmentFile(): Option[String] = { - val envFile = File(".env") - if (!envFile.exists()) { - return None - } - - val source = Source.fromFile(envFile) - try { - Some(source.mkString) - } finally { - source.close() - } - } - - private def parseEnvironmentFile( - content: String - ): Option[DatabaseConnection] = { - val keyToValueMap = content - .split("\n") - .map(line => line.trim) - .filter(line => line.nonEmpty) - .flatMap { line => - val values = line.split("=").toList - if (values.size == 2) { - Some((values.head, values(1))) - } else { - None +import com.github.ai.split.utils.toDomainError +import com.zaxxer.hikari.{HikariConfig, HikariDataSource} +import slick.jdbc.SQLiteProfile.api.* +import zio.direct.* +import zio.{Scope, ZIO} + +import java.nio.file.Files +import java.nio.file.Path + +class DatabaseConnectionFactory( + private val config: DatabaseConfig +) { + + def create(): ZIO[Scope, DomainError, Database] = defer { + val db = ZIO.acquireRelease { + ZIO + .attempt { + Files.createDirectories(Path.of("app-data", "db")) + + val hikariConfig = HikariConfig() + hikariConfig.setJdbcUrl(config.url) + hikariConfig.setDriverClassName("org.sqlite.JDBC") + hikariConfig.setMaximumPoolSize(config.maximumPoolSize) + hikariConfig.setMinimumIdle(config.minimumIdle) + hikariConfig.setConnectionInitSql("PRAGMA foreign_keys = ON") + + val dataSource = HikariDataSource(hikariConfig) + Database.forDataSource(dataSource, Some(config.maximumPoolSize)) } - } - .toMap + .mapError(_.toDomainError()) + }(db => ZIO.attempt(db.close()).orDie).run - val host = keyToValueMap.getOrElse(POSTGRES_HOST, "") - val db = keyToValueMap.getOrElse(POSTGRES_DB, "") - val user = keyToValueMap.getOrElse(POSTGRES_USER, "") - val password = keyToValueMap.getOrElse(POSTGRES_PASSWORD, "") - if (host.nonEmpty && db.nonEmpty && user.nonEmpty) { - Some( - newConnectionFrom( - host = host, - database = db, - user = user, - password = password - ) - ) - } else { - None - } + db } - - private def newConnectionFrom( - host: String, - database: String, - user: String, - password: String - ) = DatabaseConnection( - url = s"jdbc:postgresql://$host:5432/$database", - user = user, - password = password - ) -} - -object DatabaseConnectionFactory { - val POSTGRES_HOST = "POSTGRES_HOST" - val POSTGRES_DB = "POSTGRES_DB" - val POSTGRES_USER = "POSTGRES_USER" - val POSTGRES_PASSWORD = "POSTGRES_PASSWORD" } diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/CurrencyEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/CurrencyEntityDao.scala index 20e47c0..2d2a85a 100644 --- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/CurrencyEntityDao.scala +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/CurrencyEntityDao.scala @@ -5,7 +5,7 @@ import com.github.ai.split.entity.db.CurrencyEntity import com.github.ai.split.entity.exception.DomainError import com.github.ai.split.utils.some import zio.{IO, ZIO} -import slick.jdbc.PostgresProfile.api.* +import slick.jdbc.SQLiteProfile.api.* class CurrencyEntityDao( db: AppDatabase diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/Dao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/Dao.scala index f8624cc..14c03df 100644 --- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/Dao.scala +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/Dao.scala @@ -3,7 +3,7 @@ package com.github.ai.split.data.db.dao import com.github.ai.split.entity.exception.DomainError import com.github.ai.split.utils.{toDomainError, some} import zio.{IO, ZIO} -import slick.jdbc.PostgresProfile.api.* +import slick.jdbc.SQLiteProfile.api.* abstract class Dao[E, TableType <: Table[E]]( protected val db: Database, diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/ExpenseEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/ExpenseEntityDao.scala index 65328be..0b13c44 100644 --- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/ExpenseEntityDao.scala +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/ExpenseEntityDao.scala @@ -5,7 +5,7 @@ import com.github.ai.split.data.db.{given} import com.github.ai.split.entity.db.{ExpenseEntity, ExpenseUid, GroupUid} import com.github.ai.split.entity.exception.DomainError import com.github.ai.split.utils.some -import slick.jdbc.PostgresProfile.api.* +import slick.jdbc.SQLiteProfile.api.* import zio.{IO, ZIO} class ExpenseEntityDao( diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/GroupEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/GroupEntityDao.scala index 6916980..c75797e 100644 --- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/GroupEntityDao.scala +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/GroupEntityDao.scala @@ -5,7 +5,7 @@ import com.github.ai.split.data.db.{given} import com.github.ai.split.entity.db.{GroupEntity, GroupUid} import com.github.ai.split.entity.exception.DomainError import com.github.ai.split.utils.some -import slick.jdbc.PostgresProfile.api.* +import slick.jdbc.SQLiteProfile.api.* import zio.{IO, ZIO} class GroupEntityDao( diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/GroupMemberEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/GroupMemberEntityDao.scala index b5ca194..3653e8d 100644 --- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/GroupMemberEntityDao.scala +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/GroupMemberEntityDao.scala @@ -6,7 +6,7 @@ import com.github.ai.split.entity.db.{GroupMemberEntity, GroupUid, MemberUid, Us import com.github.ai.split.entity.exception.DomainError import com.github.ai.split.utils.some import zio.{IO, ZIO} -import slick.jdbc.PostgresProfile.api.* +import slick.jdbc.SQLiteProfile.api.* class GroupMemberEntityDao( db: AppDatabase diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/PaidByEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/PaidByEntityDao.scala index 8407b10..e78475f 100644 --- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/PaidByEntityDao.scala +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/PaidByEntityDao.scala @@ -4,7 +4,7 @@ import com.github.ai.split.data.db.AppDatabase import com.github.ai.split.data.db.{given} import com.github.ai.split.entity.db.{ExpenseUid, GroupUid, PaidByEntity} import com.github.ai.split.entity.exception.DomainError -import slick.jdbc.PostgresProfile.api.* +import slick.jdbc.SQLiteProfile.api.* import zio.{IO, ZIO} class PaidByEntityDao( diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/SplitBetweenEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/SplitBetweenEntityDao.scala index 71b85e7..6b1be12 100644 --- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/SplitBetweenEntityDao.scala +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/SplitBetweenEntityDao.scala @@ -4,7 +4,7 @@ import com.github.ai.split.data.db.AppDatabase import com.github.ai.split.data.db.{given} import com.github.ai.split.entity.db.{ExpenseUid, GroupUid, SplitBetweenEntity} import com.github.ai.split.entity.exception.DomainError -import slick.jdbc.PostgresProfile.api.* +import slick.jdbc.SQLiteProfile.api.* import zio.IO class SplitBetweenEntityDao( diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/UserEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/UserEntityDao.scala index b0811db..357ef59 100644 --- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/UserEntityDao.scala +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/UserEntityDao.scala @@ -7,7 +7,7 @@ import com.github.ai.split.entity.exception.DomainError import com.github.ai.split.utils.{some} import zio.{IO, ZIO} import zio.direct.* -import slick.jdbc.PostgresProfile.api.* +import slick.jdbc.SQLiteProfile.api.* class UserEntityDao( db: AppDatabase, diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/model/DatabaseConnection.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/model/DatabaseConnection.scala deleted file mode 100644 index fe873a1..0000000 --- a/backend/app/src/main/scala/com/github/ai/split/data/db/model/DatabaseConnection.scala +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.ai.split.data.db.model - -case class DatabaseConnection( - url: String, - user: String, - password: String -) diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/ApplicationConfigLoader.scala b/backend/app/src/main/scala/com/github/ai/split/domain/ApplicationConfigLoader.scala new file mode 100644 index 0000000..97fa80c --- /dev/null +++ b/backend/app/src/main/scala/com/github/ai/split/domain/ApplicationConfigLoader.scala @@ -0,0 +1,72 @@ +package com.github.ai.split.domain + +import com.github.ai.split.data.db.DatabaseConfig +import com.github.ai.split.entity.exception.{DomainError, EnvironmentError} +import com.github.ai.split.entity.{ApplicationConfig, HttpProtocol, ServerConfig} +import io.github.cdimascio.dotenv.Dotenv +import zio.* +import zio.direct.* + +class ApplicationConfigLoader { + + def loadConfig(): IO[DomainError, ApplicationConfig] = defer { + val dotenv = Dotenv.configure().ignoreIfMissing().systemProperties().load() + + val protocol = readRequired(dotenv, "PROTOCOL") + .flatMap(parseProtocol) + .run + val serverCertificatePath = readRequired(dotenv, "SERVER_CERTIFICATE_PATH").run + val serverPrivateKeyPath = readRequired(dotenv, "SERVER_PRIVATE_KEY_PATH").run + val databaseUrl = readRequired(dotenv, "DATABASE_URL").run + val databaseMaximumPoolSize = readIntRequired(dotenv, "DATABASE_MAX_POOL_SIZE").run + val databaseMinimumIdle = readIntRequired(dotenv, "DATABASE_MIN_IDLE").run + val populateTestData = readBooleanRequired(dotenv, "POPULATE_TEST_DATA").run + + ApplicationConfig( + server = ServerConfig( + protocol = protocol, + certificatePath = serverCertificatePath, + privateKeyPath = serverPrivateKeyPath + ), + database = DatabaseConfig( + url = databaseUrl, + maximumPoolSize = databaseMaximumPoolSize, + minimumIdle = databaseMinimumIdle + ), + populateTestData = populateTestData + ) + } + + private def readOptional(dotenv: Dotenv, key: String): UIO[Option[String]] = + ZIO.succeed(Option(dotenv.get(key)).map(_.trim).filter(_.nonEmpty)) + + private def readRequired(dotenv: Dotenv, key: String): IO[EnvironmentError, String] = + readOptional(dotenv, key) + .flatMap(ZIO.fromOption(_)) + .mapError(_ => EnvironmentError(s"Missing required .env value: $key")) + + private def readIntRequired(dotenv: Dotenv, key: String): IO[EnvironmentError, Int] = defer { + val value = readRequired(dotenv, key).run + ZIO + .fromOption(value.toIntOption) + .mapError(_ => EnvironmentError(s"Invalid integer value in .env variable: $key=$value")) + .run + } + + private def readBooleanRequired(dotenv: Dotenv, key: String): IO[EnvironmentError, Boolean] = defer { + val value = readRequired(dotenv, key).run + value.toLowerCase match { + case "true" => true + case "false" => false + case _ => + ZIO + .fail(EnvironmentError(s"Invalid boolean value in .env variable: $key=$value")) + .run + } + } + + private def parseProtocol(value: String): IO[EnvironmentError, HttpProtocol] = + ZIO + .fromOption(HttpProtocol.fromString(value)) + .mapError(_ => EnvironmentError(s"Invalid PROTOCOL '$value'. Expected 'http' or 'https'")) +} diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/CliArgumentParser.scala b/backend/app/src/main/scala/com/github/ai/split/domain/CliArgumentParser.scala deleted file mode 100644 index 35a5339..0000000 --- a/backend/app/src/main/scala/com/github/ai/split/domain/CliArgumentParser.scala +++ /dev/null @@ -1,72 +0,0 @@ -package com.github.ai.split.domain - -import com.github.ai.split.utils.some -import com.github.ai.split.entity.{CliArguments, HttpProtocol} -import com.github.ai.split.entity.exception.{DomainError, ParsingError} -import zio.* -import zio.direct.* - -import scala.collection.mutable - -class CliArgumentParser { - - def parse(): ZIO[ZIOAppArgs, DomainError, CliArguments] = { - defer { - val args = ZIO.service[ZIOAppArgs].run - parseArguments(args.getArgs.toList).run - } - } - - private def parseArguments(args: List[String]): IO[DomainError, CliArguments] = { - ZIO - .attempt { - var useInMemoryDb = false - var populateData = false - var protocol: Option[HttpProtocol] = None - - val queue = mutable.Queue[String]() - queue.addAll(args) - - while (queue.nonEmpty) { - val optionName = queue.removeHead() - optionName match { - case CliOptions.InMemoryDb.cliName => useInMemoryDb = true - case CliOptions.PopulateData.cliName => populateData = true - case CliOptions.Protocol.cliName => { - val protocolValue = queue - .removeHeadOption() - .flatMap(value => HttpProtocol.fromString(value)) - - protocolValue match { - case Some(p) => protocol = Some(p) - case None => - throw new ParsingError( - s"Invalid option: ${CliOptions.Protocol.cliName}. Expected 'http' or 'https'" - ) - } - } - - case _ => - throw new ParsingError(s"Invalid option specified: '$optionName'") - } - } - - if (protocol.isEmpty) { - throw new ParsingError(s"Option '${CliOptions.Protocol.cliName}' is required ") - } - - CliArguments( - isUseInMemoryDatabase = useInMemoryDb, - isPopulateTestData = populateData, - protocol = protocol.get - ) - } - .mapError(error => DomainError(cause = error.some)) - } - - private enum CliOptions(val cliName: String) { - case InMemoryDb extends CliOptions("--in-memory-db") - case PopulateData extends CliOptions("--populate-data") - case Protocol extends CliOptions("--protocol") - } -} diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/StartUpServerUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/StartUpServerUseCase.scala index b8f70f1..b68c697 100644 --- a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/StartUpServerUseCase.scala +++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/StartUpServerUseCase.scala @@ -1,7 +1,7 @@ package com.github.ai.split.domain.usecases import com.github.ai.split.data.db.AppDatabase -import com.github.ai.split.entity.CliArguments +import com.github.ai.split.entity.ApplicationConfig import com.github.ai.split.entity.exception.DomainError import zio.* import zio.direct.* @@ -10,14 +10,14 @@ class StartUpServerUseCase( private val db: AppDatabase, private val fillTestDataUseCase: FillTestDataUseCase, private val fillCurrencyDataUseCase: FillCurrencyDataUseCase, - private val cliArguments: CliArguments + private val config: ApplicationConfig ) { def startUpServer(): IO[DomainError, Unit] = { defer { db.initialize().run - if (cliArguments.isPopulateTestData) { + if (config.populateTestData) { fillTestDataUseCase.createTestData().run } diff --git a/backend/app/src/main/scala/com/github/ai/split/entity/ApplicationConfig.scala b/backend/app/src/main/scala/com/github/ai/split/entity/ApplicationConfig.scala new file mode 100644 index 0000000..adb818a --- /dev/null +++ b/backend/app/src/main/scala/com/github/ai/split/entity/ApplicationConfig.scala @@ -0,0 +1,15 @@ +package com.github.ai.split.entity + +import com.github.ai.split.data.db.DatabaseConfig + +case class ApplicationConfig( + server: ServerConfig, + database: DatabaseConfig, + populateTestData: Boolean +) + +case class ServerConfig( + protocol: HttpProtocol, + certificatePath: String, + privateKeyPath: String +) diff --git a/backend/app/src/main/scala/com/github/ai/split/entity/CliArguments.scala b/backend/app/src/main/scala/com/github/ai/split/entity/CliArguments.scala deleted file mode 100644 index eae58ba..0000000 --- a/backend/app/src/main/scala/com/github/ai/split/entity/CliArguments.scala +++ /dev/null @@ -1,16 +0,0 @@ -package com.github.ai.split.entity - -import com.github.ai.split.entity.HttpProtocol.{HTTP, HTTPS} - -case class CliArguments( - isUseInMemoryDatabase: Boolean, - isPopulateTestData: Boolean, - protocol: HttpProtocol -) { - - def getPort(): Int = { - protocol match - case HTTP => 8080 - case HTTPS => 8443 - } -} diff --git a/backend/app/src/main/scala/com/github/ai/split/entity/exception/DomainError.scala b/backend/app/src/main/scala/com/github/ai/split/entity/exception/DomainError.scala index 21731e8..d509828 100644 --- a/backend/app/src/main/scala/com/github/ai/split/entity/exception/DomainError.scala +++ b/backend/app/src/main/scala/com/github/ai/split/entity/exception/DomainError.scala @@ -13,6 +13,16 @@ class ParsingError( cause: Option[Throwable] = None ) extends DomainError(message = Some(message), cause = cause) +class EnvironmentError( + message: String, + cause: Option[Throwable] = None +) extends DomainError(message = Some(message), cause = cause) + +object EnvironmentError { + def apply(message: String): EnvironmentError = + new EnvironmentError(message) +} + class JsonDeserializationError( typeOf: Class[?], cause: Option[Throwable] = None diff --git a/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/CurrencyController.scala b/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/CurrencyController.scala index d7550ff..36c2810 100644 --- a/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/CurrencyController.scala +++ b/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/CurrencyController.scala @@ -2,38 +2,31 @@ package com.github.ai.split.presentation.controllers import com.github.ai.split.api.CurrencyDto import com.github.ai.split.api.response.GetCurrenciesResponse -import com.github.ai.split.data.JsonSerializer import com.github.ai.split.entity.db.CurrencyEntity import com.github.ai.split.data.db.repository.CurrencyRepository -import com.github.ai.split.utils.toJavaList import com.github.ai.split.entity.exception.DomainError import zio.* import zio.direct.* -import zio.http.Response class CurrencyController( - private val currencyRepository: CurrencyRepository, - private val jsonSerializer: JsonSerializer + private val currencyRepository: CurrencyRepository ) { - def getCurrencies(): IO[DomainError, Response] = { + def getCurrencies(): IO[DomainError, GetCurrenciesResponse] = { defer { val currencies = currencyRepository.getAll().run - - Response.json(jsonSerializer.serialize(createResponse(currencies))) + createResponse(currencies) } } private def createResponse(currencies: List[CurrencyEntity]): GetCurrenciesResponse = { new GetCurrenciesResponse( - currencies - .map { currency => - new CurrencyDto( - currency.isoCode, - currency.name, - currency.symbol - ) - } - .toJavaList() + currencies.map { currency => + new CurrencyDto( + currency.isoCode, + currency.name, + currency.symbol + ) + }.toList ) } } diff --git a/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/ExpenseController.scala b/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/ExpenseController.scala index 49d6276..b416c28 100644 --- a/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/ExpenseController.scala +++ b/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/ExpenseController.scala @@ -10,7 +10,6 @@ import com.github.ai.split.domain.usecases.{ } import com.github.ai.split.api.request.{PostExpenseRequest, PutExpenseRequest} import com.github.ai.split.api.response.{DeleteExpenseResponse, PostExpenseResponse, PutExpenseResponse} -import com.github.ai.split.data.JsonSerializer import com.github.ai.split.data.db.repository.ExpenseRepository import com.github.ai.split.entity.exception.DomainError import com.github.ai.split.domain.AccessResolverService @@ -35,23 +34,21 @@ class ExpenseController( private val assembleExpenseUseCase: AssembleExpenseUseCase, private val assembleGroupUseCase: AssembleGroupResponseUseCase, private val updateExpenseUseCase: UpdateExpenseUseCase, - private val removeExpenseUseCase: RemoveExpenseUseCase, - private val jsonSerializer: JsonSerializer + private val removeExpenseUseCase: RemoveExpenseUseCase ) { def createExpense( - request: Request - ): IO[DomainError, Response] = { + password: String, + body: PostExpenseRequest + ): IO[DomainError, PostExpenseResponse] = { for { - body <- jsonSerializer.deserializer(request.body.asString, classOf[PostExpenseRequest]) groupUid <- body.groupUid.parseUid().map(uid => GroupUid(uid)) - password <- parsePasswordParam(request) _ <- accessResolver.canAccessToGroup(groupUid = groupUid, password = password) - paidBy <- parsePaidBy(paidByUids = body.paidBy.toScalaList().map(_.uid)) + paidBy <- parsePaidBy(paidByUids = body.paidBy.map(_.uid)) split <- parseSplit( - isSplitEqually = Option(body.isSplitBetweenAll).exists(_.booleanValue()), - splitUids = body.splitBetween.toScalaList().map(_.uid) + isSplitEqually = body.isSplitBetweenAll.getOrElse(false), + splitUids = body.splitBetween.map(_.uid) ) expense <- addExpenseUseCase.addExpenseToGroup( @@ -65,21 +62,20 @@ class ExpenseController( ) ) expenseDto <- assembleExpenseUseCase.assembleExpenseDto(expenseUid = expense.uid) - } yield Response.json(jsonSerializer.serialize(PostExpenseResponse(expenseDto))) + } yield PostExpenseResponse(expenseDto) } def updateExpense( - request: Request - ): IO[DomainError, Response] = { + expenseId: String, + password: String, + data: PutExpenseRequest + ): IO[DomainError, PutExpenseResponse] = { for { - expenseUid <- parseUidFromUrl(request).map(uid => ExpenseUid(uid)) - password <- parsePasswordParam(request) + expenseUid <- expenseId.parseUid().map(uid => ExpenseUid(uid)) _ <- accessResolver.canAccessToExpense(expenseUid = expenseUid, password = password) - data <- jsonSerializer.deserializer(request.body.asString, classOf[PutExpenseRequest]) - newPaidBy <- { - val paidBy = data.paidBy.toScalaList() + val paidBy = data.paidBy if (paidBy.nonEmpty) { parsePaidBy( paidByUids = paidBy.map(_.uid) @@ -90,8 +86,8 @@ class ExpenseController( } newSplit <- - val splitBetween = data.splitBetween.toScalaList() - val isSplitBetweenAll = Option(data.isSplitBetweenAll).map(_.booleanValue()) + val splitBetween = data.splitBetween + val isSplitBetweenAll = data.isSplitBetweenAll if (splitBetween.nonEmpty || isSplitBetweenAll.isDefined) { parseSplit( @@ -105,23 +101,23 @@ class ExpenseController( _ <- updateExpenseUseCase.updateExpense( expenseUid = expenseUid, - newTitle = Option(data.title).map(_.trim).filter(_.nonEmpty), - newDescription = Option(data.description).map(_.trim).filter(_.nonEmpty), - newAmount = Option(data.amount).map(_.doubleValue()), + newTitle = data.title.map(_.trim).filter(_.nonEmpty), + newDescription = data.description.map(_.trim).filter(_.nonEmpty), + newAmount = data.amount, newPaidBy = newPaidBy, newSplit = newSplit ) expenseDto <- assembleExpenseUseCase.assembleExpenseDto(expenseUid = expenseUid) - } yield Response.json(jsonSerializer.serialize(PutExpenseResponse(expenseDto))) + } yield PutExpenseResponse(expenseDto) } def removeExpense( - request: Request - ): IO[DomainError, Response] = { + expenseId: String, + password: String + ): IO[DomainError, DeleteExpenseResponse] = { defer { - val password = parsePasswordParam(request).run - val expenseUid = parseUidFromUrl(request).map(uid => ExpenseUid(uid)).run + val expenseUid = expenseId.parseUid().map(uid => ExpenseUid(uid)).run accessResolver.canAccessToExpense(expenseUid, password).run val expense = expenseRepository.getEntityByUid(expenseUid).run @@ -129,7 +125,7 @@ class ExpenseController( val groupDto = assembleGroupUseCase.assembleGroupDto(expense.groupUid).run - Response.json(jsonSerializer.serialize(DeleteExpenseResponse(groupDto))) + DeleteExpenseResponse(groupDto) } } diff --git a/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/GroupController.scala b/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/GroupController.scala index 0b62a2e..25bc787 100644 --- a/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/GroupController.scala +++ b/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/GroupController.scala @@ -24,20 +24,11 @@ import com.github.ai.split.entity.{ } import com.github.ai.split.api.request.{PostGroupRequest, PutGroupRequest} import com.github.ai.split.api.response.{GetGroupsResponse, PostGroupResponse, PutGroupResponse} -import com.github.ai.split.data.JsonSerializer import com.github.ai.split.entity.Access.{DENIED, GRANTED} import com.github.ai.split.entity.FileExtension.{CSV, HTML} import com.github.ai.split.entity.db.{GroupUid, UserUid} import com.github.ai.split.entity.exception.DomainError -import com.github.ai.split.utils.{ - toJavaList, - toScalaList, - getLastUrlParameter, - parsePasswordParam, - parseUid, - parseUidFromUrl, - some -} +import com.github.ai.split.utils.{getLastUrlParameter, parsePasswordParam, parseUid, parseUidFromUrl, some} import zio.{IO, ZIO} import zio.http.{Body, Charsets, Header, Headers, MediaType, Request, Response, Status} import zio.direct.* @@ -54,16 +45,16 @@ class GroupController( private val assembleGroupUseCase: AssembleGroupResponseUseCase, private val assembleGroupsUseCase: AssembleGroupsResponseUseCase, private val updateGroupUseCase: UpdateGroupUseCase, - private val exportDataUseCase: ExportGroupDataUseCase, - private val jsonSerializer: JsonSerializer + private val exportDataUseCase: ExportGroupDataUseCase ) { def getGroups( - request: Request - ): IO[DomainError, Response] = { + ids: String, + passwordValues: String + ): IO[DomainError, GetGroupsResponse] = { for { - groupUids <- parseUids(request).map(uids => uids.map(GroupUid(_))) - passwords <- parsePasswords(request) + groupUids <- parseUids(ids).map(uids => uids.map(GroupUid(_))) + passwords = passwordValues.split(",").toList uidsAndAccesses <- accessResolver.canAccessToGroups(groupUids = groupUids, passwords = passwords) grantedGroupsUids = uidsAndAccesses @@ -84,20 +75,20 @@ class GroupController( ) } ) - } yield Response.json(jsonSerializer.serialize(GetGroupsResponse(groups.toJavaList(), errors.toJavaList()))) + } yield GetGroupsResponse(groups, errors) } def updateGroup( - request: Request - ): IO[DomainError, Response] = { + groupId: String, + password: String, + data: PutGroupRequest + ): IO[DomainError, PutGroupResponse] = { for { - groupUid <- parseUidFromUrl(request).map(uid => GroupUid(uid)) - password <- parsePasswordParam(request) + groupUid <- groupId.parseUid().map(uid => GroupUid(uid)) _ <- accessResolver.canAccessToGroup(groupUid = groupUid, password = password) - data <- jsonSerializer.deserializer(request.body.asString, classOf[PutGroupRequest]) newMembers <- { - val newMembers = data.members.toScalaList() + val newMembers = data.members if (newMembers.nonEmpty) { ZIO .collectAll( @@ -111,31 +102,27 @@ class GroupController( _ <- updateGroupUseCase.updateGroup( groupUid = groupUid, - newPassword = Option(data.password).map(_.trim).filter(_.nonEmpty), - newTitle = Option(data.title).map(_.trim).filter(_.nonEmpty), - newDescription = Option(data.description).map(_.trim).filter(_.nonEmpty), - newCurrencyIsoCode = Option(data.currencyIsoCode).map(_.trim).filter(_.nonEmpty), + newPassword = data.password.map(_.trim).filter(_.nonEmpty), + newTitle = data.title.map(_.trim).filter(_.nonEmpty), + newDescription = data.description.map(_.trim).filter(_.nonEmpty), + newCurrencyIsoCode = data.currencyIsoCode.map(_.trim).filter(_.nonEmpty), newMemberUids = newMembers ) groupDto <- assembleGroupUseCase.assembleGroupDto(groupUid = groupUid) - } yield Response.json(jsonSerializer.serialize(PutGroupResponse(groupDto))) + } yield PutGroupResponse(groupDto) } def createGroup( - request: Request - ): IO[DomainError, Response] = { + data: PostGroupRequest + ): IO[DomainError, PostGroupResponse] = { for { - data <- jsonSerializer.deserializer(request.body.asString, classOf[PostGroupRequest]) - newExpenses <- parseNewExpenses( - expenses = data.expenses.toScalaList() + expenses = data.expenses ) newGroup <- { - val newUsers = data.members - .toScalaList() - .map(member => NewUser(name = member.name)) + val newUsers = data.members.map(member => NewUser(name = member.name)) addGroupUseCase.addGroup( NewGroup( @@ -150,7 +137,7 @@ class GroupController( } groupDto <- assembleGroupUseCase.assembleGroupDto(groupUid = newGroup.uid) - } yield Response.json(jsonSerializer.serialize(PostGroupResponse(groupDto))) + } yield PostGroupResponse(groupDto) } def exportGroup( @@ -189,16 +176,15 @@ class GroupController( expenses: List[NewExpenseDto] ): IO[DomainError, List[NewExpense]] = { val newExpenses = expenses.map { expense => - val isSplitBetweenAll = Some(expense.isSplitBetweenAll).map(_.booleanValue()).getOrElse(true) + val isSplitBetweenAll = expense.isSplitBetweenAll.getOrElse(true) val splitMembers = expense.splitBetween - .toScalaList() .map(splitMember => NameReference(name = splitMember.name)) NewExpense( title = expense.title, description = expense.description, amount = expense.amount, - paidBy = expense.paidBy.toScalaList().map(payer => NameReference(name = payer.name)), + paidBy = expense.paidBy.map(payer => NameReference(name = payer.name)), split = if (isSplitBetweenAll) SplitBetweenAll else SplitBetweenMembers(splitMembers) ) } @@ -206,11 +192,10 @@ class GroupController( ZIO.succeed(newExpenses) } - private def parseUids(request: Request): IO[DomainError, List[UUID]] = { + private def parseUids(ids: String): IO[DomainError, List[UUID]] = { for { uids <- { - val uids = request.url - .queryParamOrElse("ids", "") + val uids = ids .split(",") .toList .map(id => id.parseUid()) @@ -224,15 +209,6 @@ class GroupController( } yield uids } - private def parsePasswords(request: Request): IO[DomainError, List[String]] = { - val passwords = request.url - .queryParamOrElse("passwords", "") - .split(",") - .toList - - ZIO.succeed(passwords) - } - private def parseGroupUidAndExtension( request: Request ): IO[DomainError, (GroupUid, FileExtension)] = { diff --git a/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/MemberController.scala b/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/MemberController.scala index 73eab68..c02595a 100644 --- a/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/MemberController.scala +++ b/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/MemberController.scala @@ -10,7 +10,6 @@ import com.github.ai.split.domain.usecases.{ } import com.github.ai.split.api.request.{PostMemberRequest, PutMemberRequest} import com.github.ai.split.api.response.{DeleteMemberResponse, PostMemberResponse, PutMemberResponse} -import com.github.ai.split.data.JsonSerializer import com.github.ai.split.domain.AccessResolverService import com.github.ai.split.entity.db.{GroupUid, MemberUid} import com.github.ai.split.utils.{parsePasswordParam, parseUid, parseUidFromUrl} @@ -27,16 +26,14 @@ class MemberController( private val addMemberUseCase: AddMembersUseCase, private val removeMembersUseCase: RemoveMembersUseCase, private val updateMemberUseCase: UpdateMemberUseCase, - private val assembleGroupUseCase: AssembleGroupResponseUseCase, - private val jsonSerializer: JsonSerializer + private val assembleGroupUseCase: AssembleGroupResponseUseCase ) { def createMember( - request: Request - ): IO[DomainError, Response] = { + password: String, + body: PostMemberRequest + ): IO[DomainError, PostMemberResponse] = { for { - password <- parsePasswordParam(request) - body <- jsonSerializer.deserializer(request.body.asString, classOf[PostMemberRequest]) groupUid <- body.groupUid.parseUid().map(uid => GroupUid(uid)) _ <- accessResolverService.canAccessToGroup(groupUid = groupUid, password = password) @@ -45,32 +42,32 @@ class MemberController( name = body.name ) groupDto <- assembleGroupUseCase.assembleGroupDto(groupUid) - } yield Response.json(jsonSerializer.serialize(PostMemberResponse(groupDto))) + } yield PostMemberResponse(groupDto) } def updateMember( - request: Request - ): IO[DomainError, Response] = { + memberId: String, + password: String, + body: PutMemberRequest + ): IO[DomainError, PutMemberResponse] = { defer { - val password = parsePasswordParam(request).run - val memberUid = parseUidFromUrl(request).map(uid => MemberUid(uid)).run - val body = jsonSerializer.deserializer(request.body.asString, classOf[PutMemberRequest]).run + val memberUid = memberId.parseUid().map(uid => MemberUid(uid)).run accessResolver.canAccessToMember(memberUid = memberUid, password = password).run val member = updateMemberUseCase.updateMember(memberUid = memberUid, newName = body.name).run val groupDto = assembleGroupUseCase.assembleGroupDto(groupUid = member.groupUid).run - Response.json(jsonSerializer.serialize(PutMemberResponse(groupDto))) + PutMemberResponse(groupDto) } } def removeMember( - request: Request - ): IO[DomainError, Response] = { + memberId: String, + password: String + ): IO[DomainError, DeleteMemberResponse] = { for { - password <- parsePasswordParam(request) - memberUid <- parseUidFromUrl(request).map(uid => MemberUid(uid)) + memberUid <- memberId.parseUid().map(uid => MemberUid(uid)) _ <- accessResolverService.canAccessToMember(memberUid = memberUid, password = password) group <- getGroupUseCase.getGroupByMemberUid(memberUid) @@ -78,6 +75,6 @@ class MemberController( _ <- removeMembersUseCase.removeMemberByUids(memberUids = List(memberUid)) groupDto <- assembleGroupUseCase.assembleGroupDto(groupUid = group.uid) - } yield Response.json(jsonSerializer.serialize(DeleteMemberResponse(groupDto))) + } yield DeleteMemberResponse(groupDto) } } diff --git a/backend/app/src/main/scala/com/github/ai/split/presentation/routes/CurrencyRoutes.scala b/backend/app/src/main/scala/com/github/ai/split/presentation/routes/CurrencyRoutes.scala index 70f1a6e..1b878f0 100644 --- a/backend/app/src/main/scala/com/github/ai/split/presentation/routes/CurrencyRoutes.scala +++ b/backend/app/src/main/scala/com/github/ai/split/presentation/routes/CurrencyRoutes.scala @@ -1,18 +1,16 @@ package com.github.ai.split.presentation.routes -import com.github.ai.split.utils.toDomainResponse +import com.github.ai.split.openapi.ApiEndpoints +import com.github.ai.split.utils.toErrorMessageDto import com.github.ai.split.presentation.controllers.{CurrencyController} import zio.ZIO -import zio.http.{Method, Request, Routes, handler} +import zio.http.Routes object CurrencyRoutes { def routes() = Routes( - Method.GET / "currency" -> handler { (request: Request) => - for { - controller <- ZIO.service[CurrencyController] - response <- controller.getCurrencies().mapError(_.toDomainResponse) - } yield response + ApiEndpoints.getCurrencies.implement { _ => + ZIO.serviceWithZIO[CurrencyController](_.getCurrencies()).mapError(_.toErrorMessageDto) } ) } diff --git a/backend/app/src/main/scala/com/github/ai/split/presentation/routes/ExpenseRoutes.scala b/backend/app/src/main/scala/com/github/ai/split/presentation/routes/ExpenseRoutes.scala index 51cf06f..367bffa 100644 --- a/backend/app/src/main/scala/com/github/ai/split/presentation/routes/ExpenseRoutes.scala +++ b/backend/app/src/main/scala/com/github/ai/split/presentation/routes/ExpenseRoutes.scala @@ -1,6 +1,7 @@ package com.github.ai.split.presentation.routes -import com.github.ai.split.utils.toDomainResponse +import com.github.ai.split.openapi.ApiEndpoints +import com.github.ai.split.utils.toErrorMessageDto import com.github.ai.split.presentation.controllers.ExpenseController import zio.ZIO import zio.http.* @@ -8,23 +9,20 @@ import zio.http.* object ExpenseRoutes { def routes() = Routes( - Method.POST / "expense" -> Handler.fromFunctionZIO[Request] { (request: Request) => - for { - controller <- ZIO.service[ExpenseController] - response <- controller.createExpense(request).mapError(_.toDomainResponse) - } yield response + ApiEndpoints.postExpense.implement { case (password, body) => + ZIO + .serviceWithZIO[ExpenseController](_.createExpense(password.getOrElse(""), body)) + .mapError(_.toErrorMessageDto) }, - Method.PUT / "expense" / string("expenseId") -> Handler.fromFunctionZIO[Request] { (request: Request) => - for { - controller <- ZIO.service[ExpenseController] - response <- controller.updateExpense(request).mapError(_.toDomainResponse) - } yield response + ApiEndpoints.putExpense.implement { case (expenseId, password, body) => + ZIO + .serviceWithZIO[ExpenseController](_.updateExpense(expenseId, password.getOrElse(""), body)) + .mapError(_.toErrorMessageDto) }, - Method.DELETE / "expense" / string("expenseId") -> Handler.fromFunctionZIO[Request] { (request: Request) => - for { - controller <- ZIO.service[ExpenseController] - response <- controller.removeExpense(request).mapError(_.toDomainResponse) - } yield response + ApiEndpoints.deleteExpense.implement { case (expenseId, password) => + ZIO + .serviceWithZIO[ExpenseController](_.removeExpense(expenseId, password.getOrElse(""))) + .mapError(_.toErrorMessageDto) } ) } diff --git a/backend/app/src/main/scala/com/github/ai/split/presentation/routes/GroupRoutes.scala b/backend/app/src/main/scala/com/github/ai/split/presentation/routes/GroupRoutes.scala index 439978a..0d58107 100644 --- a/backend/app/src/main/scala/com/github/ai/split/presentation/routes/GroupRoutes.scala +++ b/backend/app/src/main/scala/com/github/ai/split/presentation/routes/GroupRoutes.scala @@ -1,32 +1,28 @@ package com.github.ai.split.presentation.routes -import com.github.ai.split.utils.toDomainResponse -import com.github.ai.split.domain.AuthService -import com.github.ai.split.entity.AuthenticationContext +import com.github.ai.split.openapi.ApiEndpoints +import com.github.ai.split.utils.toErrorMessageDto import com.github.ai.split.presentation.controllers.GroupController import zio.ZIO -import zio.http.{Handler, Method, Request, Response, Routes, handler, string} +import zio.http.Routes object GroupRoutes { def routes() = Routes( - Method.GET / "group" -> handler { (request: Request) => - for { - controller <- ZIO.service[GroupController] - response <- controller.getGroups(request).mapError(_.toDomainResponse) - } yield response + ApiEndpoints.getGroups.implement { case (ids, passwords) => + ZIO + .serviceWithZIO[GroupController](_.getGroups(ids, passwords)) + .mapError(_.toErrorMessageDto) }, - Method.POST / "group" -> handler { (request: Request) => - for { - controller <- ZIO.service[GroupController] - response <- controller.createGroup(request).mapError(_.toDomainResponse) - } yield response + ApiEndpoints.postGroup.implement { body => + ZIO + .serviceWithZIO[GroupController](_.createGroup(body)) + .mapError(_.toErrorMessageDto) }, - Method.PUT / "group" / string("groupId") -> handler { (request: Request) => - for { - controller <- ZIO.service[GroupController] - response <- controller.updateGroup(request).mapError(_.toDomainResponse) - } yield response + ApiEndpoints.putGroup.implement { case (groupId, password, body) => + ZIO + .serviceWithZIO[GroupController](_.updateGroup(groupId, password.getOrElse(""), body)) + .mapError(_.toErrorMessageDto) } ) } diff --git a/backend/app/src/main/scala/com/github/ai/split/presentation/routes/MemberRoutes.scala b/backend/app/src/main/scala/com/github/ai/split/presentation/routes/MemberRoutes.scala index a52475f..eb02bbb 100644 --- a/backend/app/src/main/scala/com/github/ai/split/presentation/routes/MemberRoutes.scala +++ b/backend/app/src/main/scala/com/github/ai/split/presentation/routes/MemberRoutes.scala @@ -1,30 +1,28 @@ package com.github.ai.split.presentation.routes -import com.github.ai.split.utils.toDomainResponse +import com.github.ai.split.openapi.ApiEndpoints +import com.github.ai.split.utils.toErrorMessageDto import com.github.ai.split.presentation.controllers.MemberController import zio.* -import zio.http.{string, *} +import zio.http.Routes object MemberRoutes { def routes() = Routes( - Method.POST / "member" -> handler { (request: Request) => - for { - controller <- ZIO.service[MemberController] - response <- controller.createMember(request).mapError(_.toDomainResponse) - } yield response + ApiEndpoints.postMember.implement { case (password, body) => + ZIO + .serviceWithZIO[MemberController](_.createMember(password.getOrElse(""), body)) + .mapError(_.toErrorMessageDto) }, - Method.PUT / "member" / string("memberId") -> handler { (request: Request) => - for { - controller <- ZIO.service[MemberController] - response <- controller.updateMember(request).mapError(_.toDomainResponse) - } yield response + ApiEndpoints.putMember.implement { case (memberId, password, body) => + ZIO + .serviceWithZIO[MemberController](_.updateMember(memberId, password.getOrElse(""), body)) + .mapError(_.toErrorMessageDto) }, - Method.DELETE / "member" / string("memberId") -> handler { (request: Request) => - for { - controller <- ZIO.service[MemberController] - response <- controller.removeMember(request).mapError(_.toDomainResponse) - } yield response + ApiEndpoints.deleteMember.implement { case (memberId, password) => + ZIO + .serviceWithZIO[MemberController](_.removeMember(memberId, password.getOrElse(""))) + .mapError(_.toErrorMessageDto) } ) } diff --git a/backend/app/src/main/scala/com/github/ai/split/utils/AnsiStrippingEncoder.scala b/backend/app/src/main/scala/com/github/ai/split/utils/AnsiStrippingEncoder.scala new file mode 100644 index 0000000..6fc8395 --- /dev/null +++ b/backend/app/src/main/scala/com/github/ai/split/utils/AnsiStrippingEncoder.scala @@ -0,0 +1,22 @@ +package com.github.ai.split.utils + +import ch.qos.logback.classic.encoder.PatternLayoutEncoder +import ch.qos.logback.classic.spi.ILoggingEvent +import zio.http.Charsets + +class AnsiStrippingEncoder extends PatternLayoutEncoder { + + // Regular expression to match ANSI escape sequences + // Matches patterns like: \u001B[34m, \u001B[0m, [34m, [0m, etc. + private val ansiPattern = "\\u001B\\[[;\\d]*m|\\[\\d+m".r + + override def encode(event: ILoggingEvent): Array[Byte] = { + // Get the encoded message from the parent encoder + val encoded = super.encode(event) + + // Convert to string, strip ANSI codes, and convert back to bytes + val message = new String(encoded, Charsets.Utf8) + val stripped = ansiPattern.replaceAllIn(message, "") + stripped.getBytes(Charsets.Utf8) + } +} diff --git a/backend/app/src/main/scala/com/github/ai/split/utils/CollectionExtensions.scala b/backend/app/src/main/scala/com/github/ai/split/utils/CollectionExtensions.scala deleted file mode 100644 index ecb0b06..0000000 --- a/backend/app/src/main/scala/com/github/ai/split/utils/CollectionExtensions.scala +++ /dev/null @@ -1,25 +0,0 @@ -package com.github.ai.split.utils - -import java.util.Properties -import scala.jdk.CollectionConverters.* - -extension [T](list: List[T]) { - def toJavaList(): java.util.List[T] = list.asJava -} - -extension [T](javaList: java.util.List[T]) { - def toScalaList(): List[T] = javaList.asScala.toList -} - -extension (values: Map[String, String]) { - - def toProperties(): Properties = { - val properties = Properties() - - for ((key, value) <- values) { - properties.put(key, value) - } - - properties - } -} diff --git a/backend/app/src/main/scala/com/github/ai/split/utils/DataConverters.scala b/backend/app/src/main/scala/com/github/ai/split/utils/DataConverters.scala index e39dbff..5ab5716 100644 --- a/backend/app/src/main/scala/com/github/ai/split/utils/DataConverters.scala +++ b/backend/app/src/main/scala/com/github/ai/split/utils/DataConverters.scala @@ -74,8 +74,8 @@ def toExpenseDto( expense.description, expense.amount, toCurrencyDto(currency), - paidByUsers.toJavaList(), - splitBetweenUsers.toJavaList(), + paidByUsers, + splitBetweenUsers, toTimestampDto(expense.created), toTimestampDto(expense.modified) ) @@ -163,9 +163,9 @@ def toGroupDto( group.title, group.description, toCurrencyDto(currency), - memberDtos.toJavaList(), - transformedExpenses.toJavaList(), - paybackTransactions.map(transaction => toTransactionDto(transaction)).toJavaList(), + memberDtos, + transformedExpenses, + paybackTransactions.map(transaction => toTransactionDto(transaction)), toTimestampDto(group.created), toTimestampDto(group.modified) ) diff --git a/backend/app/src/main/scala/com/github/ai/split/utils/RequestLogger.scala b/backend/app/src/main/scala/com/github/ai/split/utils/RequestLogger.scala new file mode 100644 index 0000000..c3abd6c --- /dev/null +++ b/backend/app/src/main/scala/com/github/ai/split/utils/RequestLogger.scala @@ -0,0 +1,42 @@ +package com.github.ai.split.utils + +import zio.{Clock, ZIO} +import zio.direct.* +import zio.http.{Middleware, Request, Routes, handler} + +object RequestLogger { + + val requestLogger: Middleware[Any] = new Middleware[Any] { + def apply[Env1 <: Any, Err](routes: Routes[Env1, Err]): Routes[Env1, Err] = + routes.transform[Env1] { requestHandler => + handler { (request: Request) => + defer { + val startTime = Clock.nanoTime.run + ZIO.logInfo(s"-> ${request.method} ${request.url.encode}").run + + val response = requestHandler + .apply(request) + .tapError(err => ZIO.logError(s"<- ${request.method} ${request.url.encode} error=$err")) + .run + + val endTime = Clock.nanoTime.run + val elapsedTime = (endTime - startTime) / 1000000L + + ZIO + .logInfo( + s"<- %s %s %s %sms" + .format( + request.method, + request.url.encode, + response.status.code, + elapsedTime + ) + ) + .run + + response + } + } + } + } +} diff --git a/backend/app/src/main/scala/com/github/ai/split/utils/ResponseExtensions.scala b/backend/app/src/main/scala/com/github/ai/split/utils/ResponseExtensions.scala index e427b84..2c6e4e9 100644 --- a/backend/app/src/main/scala/com/github/ai/split/utils/ResponseExtensions.scala +++ b/backend/app/src/main/scala/com/github/ai/split/utils/ResponseExtensions.scala @@ -3,15 +3,15 @@ package com.github.ai.split.utils import com.github.ai.split.api.ErrorMessageDto import com.github.ai.split.entity.exception.DomainError import com.github.ai.split.utils.* -import com.google.gson.GsonBuilder import zio.http.{Body, Response, Status} +import zio.json.{DeriveJsonEncoder, JsonEncoder, EncoderOps} import java.nio.charset.StandardCharsets.UTF_8 import java.util.Base64 import scala.annotation.tailrec extension (exception: DomainError) { - def toDomainResponse: Response = { + def toErrorMessageDto: ErrorMessageDto = { val hasMessage = exception.message.isDefined val hasCause = exception.cause.isDefined @@ -30,18 +30,19 @@ extension (exception: DomainError) { .toList val response = ErrorMessageDto( - if (hasMessage) exception.message.map(_.trim).getOrElse("") else null, + if (hasMessage) exception.message.map(_.trim) else None, exceptionToPrint.toString.trim, encodedStacktrace, - stacktraceLines.toJavaList() + stacktraceLines ) + response + } - // TODO: use JsonSerializable - val gson = GsonBuilder().setPrettyPrinting().create() - + def toDomainResponse: Response = { + given JsonEncoder[ErrorMessageDto] = DeriveJsonEncoder.gen Response.error( status = Status.BadRequest, - body = Body.fromString(gson.toJson(gson), UTF_8) + body = Body.fromString(exception.toErrorMessageDto.toJson, UTF_8) ) } diff --git a/backend/build.sbt b/backend/build.sbt index 42177a6..1368074 100644 --- a/backend/build.sbt +++ b/backend/build.sbt @@ -4,11 +4,12 @@ val zioJsonVersion = "0.6.2" val circeVersion = "0.14.10" val zioDirect = "1.0.0-RC7" val zioHttp = "3.0.1" -val gsonVersion = "2.11.0" ThisBuild / scalaVersion := scala3Version ThisBuild / version := "0.1.0" +lazy val generateOpenApi = taskKey[File]("Generate the OpenAPI YAML schema") + lazy val api = project .in(file("api")) .settings( @@ -17,13 +18,40 @@ lazy val api = project artifact.name + "." + artifact.extension }, libraryDependencies ++= Seq( - "com.google.code.gson" % "gson" % gsonVersion + "dev.zio" %% "zio-schema" % "1.4.1" ) ) +lazy val openapiSchema = project + .in(file("openapi-schema")) + .dependsOn(api) + .settings( + name := "simple-split-openapi-schema", + libraryDependencies ++= Seq( + "dev.zio" %% "zio-http" % zioHttp, + "org.yaml" % "snakeyaml" % "2.0", + "org.scalameta" %% "munit" % "1.0.0" % Test + ), + generateOpenApi := { + val output = baseDirectory.value / "openapi.yaml" + val classpath = (Compile / fullClasspath).value.files + val appRunner = (Compile / runner).value + appRunner + .run( + "com.github.ai.split.openapi.GenerateOpenApi", + classpath, + Array(output.getAbsolutePath), + streams.value.log + ) + .get + streams.value.log.info(s"Generated $output") + output + } + ) + lazy val app = project .in(file("app")) - .dependsOn(api) + .dependsOn(api, openapiSchema) .settings( name := "simple-split-app", assembly / assemblyMergeStrategy := { @@ -46,20 +74,20 @@ lazy val app = project "dev.zio" %% "zio-http" % zioHttp, "dev.zio" %% "zio-json" % zioJsonVersion, "dev.zio" %% "zio-direct" % zioDirect, + "io.github.cdimascio" % "dotenv-java" % "3.2.0", // Logging "dev.zio" %% "zio-logging" % "2.3.2", "dev.zio" %% "zio-logging-slf4j" % "2.3.1", - "ch.qos.logback" % "logback-classic" % "1.5.11", + "ch.qos.logback" % "logback-classic" % "1.5.13", // JWT "com.auth0" % "java-jwt" % "4.5.0", // Database - "com.h2database" % "h2" % "2.3.232", - "org.postgresql" % "postgresql" % "42.7.3", "com.typesafe.slick" %% "slick" % "3.6.1", "com.typesafe.slick" %% "slick-hikaricp" % "3.6.1", + "org.xerial" % "sqlite-jdbc" % "3.51.1.0", // Password Hashing "org.mindrot" % "jbcrypt" % "0.4", @@ -68,7 +96,7 @@ lazy val app = project lazy val apiClient = project .in(file("api-client")) - .dependsOn(api) + .dependsOn(api, openapiSchema) .settings( name := "simple-split-api-client", assembly / assemblyMergeStrategy := { @@ -83,4 +111,4 @@ lazy val apiClient = project "dev.zio" %% "zio-direct" % zioDirect, "dev.zio" %% "zio-http" % zioHttp ) - ) \ No newline at end of file + ) diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml index a89c1ea..a091e27 100644 --- a/backend/docker-compose.yaml +++ b/backend/docker-compose.yaml @@ -1,40 +1,21 @@ version: "3.8" services: - db: - image: postgres:15 - restart: always - environment: - - POSTGRES_DB=${POSTGRES_DB} - - POSTGRES_USER=${POSTGRES_USER} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - ports: - - "5432:5432" - volumes: - - ss-db:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 5s - timeout: 5s - retries: 5 - app: build: . ports: - "4281:8080" - "4282:8443" - depends_on: - db: - condition: service_healthy environment: - - POSTGRES_HOST=db - - POSTGRES_DB=${POSTGRES_DB} - - POSTGRES_USER=${POSTGRES_USER} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - PROTOCOL=http + - SERVER_CERTIFICATE_PATH=/keys/server.crt + - SERVER_PRIVATE_KEY_PATH=/keys/server.key + - DATABASE_URL=jdbc:sqlite:/app-data/db/simple-split.sqlite?journal_mode=WAL&busy_timeout=30000 + - DATABASE_MAX_POOL_SIZE=4 + - DATABASE_MIN_IDLE=1 + - POPULATE_TEST_DATA=false volumes: - ss-app-data:/app-data volumes: - ss-db: - driver: local ss-app-data: driver: local diff --git a/backend/openapi-schema/openapi.yaml b/backend/openapi-schema/openapi.yaml new file mode 100644 index 0000000..9f7927b --- /dev/null +++ b/backend/openapi-schema/openapi.yaml @@ -0,0 +1,861 @@ +openapi: 3.1.0 +info: + title: Simple Split API + version: 0.1.0 +paths: + /expense: + description: | + - expenses + post: + tags: + - expenses + description: | + - expenses + parameters: + - name: password + in: query + required: true + schema: + type: + - string + - 'null' + allowReserved: false + style: form + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PostExpenseRequest' + required: true + responses: + '200': + description: |+ + Created expense + + content: + application/json: + schema: + $ref: '#/components/schemas/PostExpenseResponse' + '400': + description: |+ + Bad request + + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessageDto' + /currency: + description: | + - currencies + get: + tags: + - currencies + description: | + - currencies + responses: + '200': + description: |+ + Supported currencies + + content: + application/json: + schema: + $ref: '#/components/schemas/GetCurrenciesResponse' + '400': + description: |+ + Bad request + + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessageDto' + /member/{memberId}: + description: | + - members + put: + tags: + - members + description: | + - members + parameters: + - name: password + in: query + required: true + schema: + type: + - string + - 'null' + allowReserved: false + style: form + - name: memberId + in: path + required: true + schema: + type: string + style: simple + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PutMemberRequest' + required: true + responses: + '200': + description: |+ + Group containing the updated member + + content: + application/json: + schema: + $ref: '#/components/schemas/PutMemberResponse' + '400': + description: |+ + Bad request + + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessageDto' + delete: + tags: + - members + description: | + - members + parameters: + - name: password + in: query + required: true + schema: + type: + - string + - 'null' + allowReserved: false + style: form + - name: memberId + in: path + required: true + schema: + type: string + style: simple + responses: + '200': + description: |+ + Group after removing the member + + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteMemberResponse' + '400': + description: |+ + Bad request + + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessageDto' + /export/{groupIdAndExtension}: + description: | + - groups + get: + tags: + - groups + description: | + - groups + parameters: + - name: password + in: query + required: true + schema: + type: + - string + - 'null' + allowReserved: false + style: form + - name: groupIdAndExtension + in: path + required: true + schema: + type: string + style: simple + responses: + '200': + description: |+ + Exported group data + + content: + text/csv: + schema: + type: string + '400': + description: |+ + Bad request + + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessageDto' + /expense/{expenseId}: + description: | + - expenses + put: + tags: + - expenses + description: | + - expenses + parameters: + - name: password + in: query + required: true + schema: + type: + - string + - 'null' + allowReserved: false + style: form + - name: expenseId + in: path + required: true + schema: + type: string + style: simple + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PutExpenseRequest' + required: true + responses: + '200': + description: |+ + Updated expense + + content: + application/json: + schema: + $ref: '#/components/schemas/PutExpenseResponse' + '400': + description: |+ + Bad request + + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessageDto' + delete: + tags: + - expenses + description: | + - expenses + parameters: + - name: password + in: query + required: true + schema: + type: + - string + - 'null' + allowReserved: false + style: form + - name: expenseId + in: path + required: true + schema: + type: string + style: simple + responses: + '200': + description: |+ + Group after removing the expense + + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteExpenseResponse' + '400': + description: |+ + Bad request + + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessageDto' + /member: + description: | + - members + post: + tags: + - members + description: | + - members + parameters: + - name: password + in: query + required: true + schema: + type: + - string + - 'null' + allowReserved: false + style: form + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PostMemberRequest' + required: true + responses: + '200': + description: |+ + Group containing the created member + + content: + application/json: + schema: + $ref: '#/components/schemas/PostMemberResponse' + '400': + description: |+ + Bad request + + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessageDto' + /group: + description: | + - groups + get: + tags: + - groups + description: | + - groups + parameters: + - name: ids + in: query + required: true + schema: + type: string + allowReserved: false + style: form + - name: passwords + in: query + required: true + schema: + type: string + allowReserved: false + style: form + responses: + '200': + description: |+ + Groups matching the supplied credentials + + content: + application/json: + schema: + $ref: '#/components/schemas/GetGroupsResponse' + '400': + description: |+ + Bad request + + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessageDto' + post: + tags: + - groups + description: | + - groups + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PostGroupRequest' + required: true + responses: + '200': + description: |+ + Created group + + content: + application/json: + schema: + $ref: '#/components/schemas/PostGroupResponse' + '400': + description: |+ + Bad request + + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessageDto' + /group/{groupId}: + description: | + - groups + put: + tags: + - groups + description: | + - groups + parameters: + - name: password + in: query + required: true + schema: + type: + - string + - 'null' + allowReserved: false + style: form + - name: groupId + in: path + required: true + schema: + type: string + style: simple + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PutGroupRequest' + required: true + responses: + '200': + description: |+ + Updated group + + content: + application/json: + schema: + $ref: '#/components/schemas/PutGroupResponse' + '400': + description: |+ + Bad request + + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessageDto' +components: + schemas: + GroupDto: + type: object + properties: + description: + type: string + paybackTransactions: + type: array + items: + $ref: '#/components/schemas/TransactionDto' + modified: + $ref: '#/components/schemas/TimestampDto' + members: + type: array + items: + $ref: '#/components/schemas/MemberDto' + title: + type: string + expenses: + type: array + items: + $ref: '#/components/schemas/ExpenseDto' + created: + $ref: '#/components/schemas/TimestampDto' + currency: + $ref: '#/components/schemas/CurrencyDto' + uid: + type: string + required: + - uid + - title + - description + - currency + - members + - expenses + - paybackTransactions + - created + - modified + PostMemberRequest: + type: object + properties: + groupUid: + type: string + name: + type: string + required: + - groupUid + - name + GetCurrenciesResponse: + type: object + properties: + currencies: + type: array + items: + $ref: '#/components/schemas/CurrencyDto' + required: + - currencies + UserUidDto: + type: object + properties: + uid: + type: string + required: + - uid + PostGroupResponse: + type: object + properties: + group: + $ref: '#/components/schemas/GroupDto' + required: + - group + PutExpenseRequest: + type: object + properties: + splitBetween: + type: array + items: + $ref: '#/components/schemas/UserUidDto' + description: If not set, this field defaults to the value of the default annotation. + default: [ + ] + description: + type: + - string + - 'null' + description: If not set, this field defaults to the value of the default annotation. + default: null + amount: + type: + - number + - 'null' + format: double + description: If not set, this field defaults to the value of the default annotation. + default: null + isSplitBetweenAll: + type: + - boolean + - 'null' + description: If not set, this field defaults to the value of the default annotation. + default: null + paidBy: + type: array + items: + $ref: '#/components/schemas/UserUidDto' + description: If not set, this field defaults to the value of the default annotation. + default: [ + ] + title: + type: + - string + - 'null' + description: If not set, this field defaults to the value of the default annotation. + default: null + PostExpenseResponse: + type: object + properties: + expense: + $ref: '#/components/schemas/ExpenseDto' + required: + - expense + PutMemberResponse: + type: object + properties: + group: + $ref: '#/components/schemas/GroupDto' + required: + - group + PostExpenseRequest: + type: object + properties: + splitBetween: + type: array + items: + $ref: '#/components/schemas/UserUidDto' + description: + type: string + isSplitBetweenAll: + type: + - boolean + - 'null' + paidBy: + type: array + items: + $ref: '#/components/schemas/UserUidDto' + title: + type: string + groupUid: + type: string + amount: + type: number + format: double + required: + - groupUid + - title + - description + - amount + - paidBy + - splitBetween + NewExpenseDto: + type: object + properties: + splitBetween: + type: array + items: + $ref: '#/components/schemas/UserNameDto' + description: + type: string + amount: + type: number + format: double + isSplitBetweenAll: + type: + - boolean + - 'null' + paidBy: + type: array + items: + $ref: '#/components/schemas/UserNameDto' + title: + type: string + required: + - title + - description + - amount + - paidBy + - splitBetween + UserNameDto: + type: object + properties: + name: + type: string + required: + - name + GetGroupsResponse: + type: object + properties: + groups: + type: array + items: + $ref: '#/components/schemas/GroupDto' + errors: + type: array + items: + $ref: '#/components/schemas/GetGroupErrorDto' + required: + - groups + - errors + DeleteExpenseResponse: + type: object + properties: + group: + $ref: '#/components/schemas/GroupDto' + required: + - group + CurrencyDto: + type: object + properties: + isoCode: + type: string + name: + type: string + symbol: + type: string + required: + - isoCode + - name + - symbol + ExpenseDto: + type: object + properties: + splitBetween: + type: array + items: + $ref: '#/components/schemas/MemberDto' + description: + type: string + amount: + type: number + format: double + modified: + $ref: '#/components/schemas/TimestampDto' + paidBy: + type: array + items: + $ref: '#/components/schemas/MemberDto' + title: + type: string + created: + $ref: '#/components/schemas/TimestampDto' + currency: + $ref: '#/components/schemas/CurrencyDto' + uid: + type: string + required: + - uid + - title + - description + - amount + - currency + - paidBy + - splitBetween + - created + - modified + PutGroupRequest: + type: object + properties: + currencyIsoCode: + type: + - string + - 'null' + description: If not set, this field defaults to the value of the default annotation. + default: null + description: + type: + - string + - 'null' + description: If not set, this field defaults to the value of the default annotation. + default: null + members: + type: array + items: + $ref: '#/components/schemas/UserUidDto' + description: If not set, this field defaults to the value of the default annotation. + default: [ + ] + title: + type: + - string + - 'null' + description: If not set, this field defaults to the value of the default annotation. + default: null + password: + type: + - string + - 'null' + description: If not set, this field defaults to the value of the default annotation. + default: null + PostMemberResponse: + type: object + properties: + group: + $ref: '#/components/schemas/GroupDto' + required: + - group + PutExpenseResponse: + type: object + properties: + expense: + $ref: '#/components/schemas/ExpenseDto' + required: + - expense + TransactionDto: + type: object + properties: + creditorUid: + type: string + debtorUid: + type: string + amount: + type: number + format: double + required: + - creditorUid + - debtorUid + - amount + PostGroupRequest: + type: object + properties: + currencyIsoCode: + type: string + description: + type: string + members: + type: array + items: + $ref: '#/components/schemas/UserNameDto' + title: + type: string + expenses: + type: array + items: + $ref: '#/components/schemas/NewExpenseDto' + password: + type: string + required: + - password + - title + - description + - currencyIsoCode + - members + - expenses + PutGroupResponse: + type: object + properties: + group: + $ref: '#/components/schemas/GroupDto' + required: + - group + MemberDto: + type: object + properties: + uid: + type: string + name: + type: string + required: + - uid + - name + TimestampDto: + type: object + properties: + timestampSeconds: + type: integer + format: int64 + formatted: + type: string + required: + - timestampSeconds + - formatted + GetGroupErrorDto: + type: object + properties: + uid: + type: string + message: + type: string + required: + - uid + - message + ErrorMessageDto: + type: object + properties: + message: + type: + - string + - 'null' + exception: + type: string + stacktraceBase64: + type: string + stacktraceLines: + type: array + items: + type: string + required: + - exception + - stacktraceBase64 + - stacktraceLines + DeleteMemberResponse: + type: object + properties: + group: + $ref: '#/components/schemas/GroupDto' + required: + - group + PutMemberRequest: + type: object + properties: + name: + type: string + required: + - name diff --git a/backend/openapi-schema/src/main/scala/com/github/ai/split/openapi/ApiEndpoints.scala b/backend/openapi-schema/src/main/scala/com/github/ai/split/openapi/ApiEndpoints.scala new file mode 100644 index 0000000..9ebf6f2 --- /dev/null +++ b/backend/openapi-schema/src/main/scala/com/github/ai/split/openapi/ApiEndpoints.scala @@ -0,0 +1,119 @@ +package com.github.ai.split.openapi + +import com.github.ai.split.api.ErrorMessageDto +import com.github.ai.split.api.request.* +import com.github.ai.split.api.response.* +import com.github.ai.split.openapi.Schemas.given +import zio.http.* +import zio.http.codec.{Doc, HttpContentCodec, QueryCodec} +import zio.http.endpoint.Endpoint +import zio.http.endpoint.openapi.{OpenAPI, OpenAPIGen} +import zio.schema.Schema + +object ApiEndpoints { + private val defaultStringCodec = + HttpContentCodec.fromSchema[String](using Schema[String]) + + private given csvStringCodec: HttpContentCodec[String] = + HttpContentCodec( + defaultStringCodec.choices.updated( + MediaType.text.csv, + defaultStringCodec.choices(MediaType.text.plain) + ) + ) + + private def endpoint[PathInput](route: RoutePattern[PathInput]) = + Endpoint(route).outError[ErrorMessageDto](Status.BadRequest, Doc.p("Bad request")) + + val getGroups = + endpoint(Method.GET / "group") + .query(QueryCodec.query[String]("ids")) + .query(QueryCodec.query[String]("passwords")) + .out[GetGroupsResponse](Doc.p("Groups matching the supplied credentials")) + .tag("groups") + + val postGroup = + endpoint(Method.POST / "group") + .in[PostGroupRequest] + .out[PostGroupResponse](Doc.p("Created group")) + .tag("groups") + + val putGroup = + endpoint(Method.PUT / "group" / string("groupId")) + .query(QueryCodec.query[String]("password").optional) + .in[PutGroupRequest] + .out[PutGroupResponse](Doc.p("Updated group")) + .tag("groups") + + val postMember = + endpoint(Method.POST / "member") + .query(QueryCodec.query[String]("password").optional) + .in[PostMemberRequest] + .out[PostMemberResponse](Doc.p("Group containing the created member")) + .tag("members") + + val putMember = + endpoint(Method.PUT / "member" / string("memberId")) + .query(QueryCodec.query[String]("password").optional) + .in[PutMemberRequest] + .out[PutMemberResponse](Doc.p("Group containing the updated member")) + .tag("members") + + val deleteMember = + endpoint(Method.DELETE / "member" / string("memberId")) + .query(QueryCodec.query[String]("password").optional) + .out[DeleteMemberResponse](Doc.p("Group after removing the member")) + .tag("members") + + val postExpense = + endpoint(Method.POST / "expense") + .query(QueryCodec.query[String]("password").optional) + .in[PostExpenseRequest] + .out[PostExpenseResponse](Doc.p("Created expense")) + .tag("expenses") + + val putExpense = + endpoint(Method.PUT / "expense" / string("expenseId")) + .query(QueryCodec.query[String]("password").optional) + .in[PutExpenseRequest] + .out[PutExpenseResponse](Doc.p("Updated expense")) + .tag("expenses") + + val deleteExpense = + endpoint(Method.DELETE / "expense" / string("expenseId")) + .query(QueryCodec.query[String]("password").optional) + .out[DeleteExpenseResponse](Doc.p("Group after removing the expense")) + .tag("expenses") + + val getCurrencies = + endpoint(Method.GET / "currency") + .out[GetCurrenciesResponse](Doc.p("Supported currencies")) + .tag("currencies") + + val exportGroup = + endpoint(Method.GET / "export" / string("groupIdAndExtension")) + .query(QueryCodec.query[String]("password").optional) + .out[String](MediaType.text.csv, Doc.p("Exported group data")) + .tag("groups") + + val all = List( + getGroups, + postGroup, + putGroup, + postMember, + putMember, + deleteMember, + postExpense, + putExpense, + deleteExpense, + getCurrencies, + exportGroup + ) + + val openApi: OpenAPI = + OpenAPIGen.fromEndpoints( + title = "Simple Split API", + version = "0.1.0", + endpoints = all + ) +} diff --git a/backend/openapi-schema/src/main/scala/com/github/ai/split/openapi/GenerateOpenApi.scala b/backend/openapi-schema/src/main/scala/com/github/ai/split/openapi/GenerateOpenApi.scala new file mode 100644 index 0000000..2d160bb --- /dev/null +++ b/backend/openapi-schema/src/main/scala/com/github/ai/split/openapi/GenerateOpenApi.scala @@ -0,0 +1,26 @@ +package com.github.ai.split.openapi + +import org.yaml.snakeyaml.{DumperOptions, Yaml} + +import java.nio.charset.StandardCharsets.UTF_8 +import java.nio.file.{Files, Path} + +object GenerateOpenApi { + def main(args: Array[String]): Unit = { + val output = args.headOption + .map(Path.of(_)) + .getOrElse(Path.of("openapi.yaml")) + + val options = DumperOptions() + options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK) + options.setPrettyFlow(true) + options.setSplitLines(false) + + val yaml = Yaml(options) + val openApiData = yaml.load[Any](ApiEndpoints.openApi.toJson) + val contents = yaml.dump(openApiData) + + Option(output.getParent).foreach(Files.createDirectories(_)) + Files.writeString(output, contents, UTF_8) + } +} diff --git a/backend/openapi-schema/src/main/scala/com/github/ai/split/openapi/Schemas.scala b/backend/openapi-schema/src/main/scala/com/github/ai/split/openapi/Schemas.scala new file mode 100644 index 0000000..dd8a3f1 --- /dev/null +++ b/backend/openapi-schema/src/main/scala/com/github/ai/split/openapi/Schemas.scala @@ -0,0 +1,38 @@ +package com.github.ai.split.openapi + +import com.github.ai.split.api.* +import com.github.ai.split.api.request.* +import com.github.ai.split.api.response.* +import zio.schema.{DeriveSchema, Schema} + +object Schemas { + given Schema[CurrencyDto] = DeriveSchema.gen + given Schema[TimestampDto] = DeriveSchema.gen + given Schema[MemberDto] = DeriveSchema.gen + given Schema[UserNameDto] = DeriveSchema.gen + given Schema[UserUidDto] = DeriveSchema.gen + given Schema[TransactionDto] = DeriveSchema.gen + given Schema[ExpenseDto] = DeriveSchema.gen + given Schema[NewExpenseDto] = DeriveSchema.gen + given Schema[GroupDto] = DeriveSchema.gen + given Schema[GetGroupErrorDto] = DeriveSchema.gen + given Schema[ErrorMessageDto] = DeriveSchema.gen + + given Schema[PostGroupRequest] = DeriveSchema.gen + given Schema[PutGroupRequest] = DeriveSchema.gen + given Schema[PostMemberRequest] = DeriveSchema.gen + given Schema[PutMemberRequest] = DeriveSchema.gen + given Schema[PostExpenseRequest] = DeriveSchema.gen + given Schema[PutExpenseRequest] = DeriveSchema.gen + + given Schema[GetCurrenciesResponse] = DeriveSchema.gen + given Schema[GetGroupsResponse] = DeriveSchema.gen + given Schema[PostGroupResponse] = DeriveSchema.gen + given Schema[PutGroupResponse] = DeriveSchema.gen + given Schema[PostMemberResponse] = DeriveSchema.gen + given Schema[PutMemberResponse] = DeriveSchema.gen + given Schema[DeleteMemberResponse] = DeriveSchema.gen + given Schema[PostExpenseResponse] = DeriveSchema.gen + given Schema[PutExpenseResponse] = DeriveSchema.gen + given Schema[DeleteExpenseResponse] = DeriveSchema.gen +} diff --git a/backend/openapi-schema/src/test/scala/com/github/ai/split/openapi/ApiEndpointsSuite.scala b/backend/openapi-schema/src/test/scala/com/github/ai/split/openapi/ApiEndpointsSuite.scala new file mode 100644 index 0000000..b47da66 --- /dev/null +++ b/backend/openapi-schema/src/test/scala/com/github/ai/split/openapi/ApiEndpointsSuite.scala @@ -0,0 +1,18 @@ +package com.github.ai.split.openapi + +class ApiEndpointsSuite extends munit.FunSuite { + test("OpenAPI schema initializes with CSV export endpoint") { + val schema = ApiEndpoints.openApi.toJson + + assert(schema.contains("/export/{groupIdAndExtension}")) + assert(schema.contains("text/csv")) + } + + test("generated schema can be represented as YAML") { + GenerateOpenApi.main(Array("target/test-openapi.yaml")) + + val schema = java.nio.file.Files.readString(java.nio.file.Path.of("target/test-openapi.yaml")) + assert(schema.contains("openapi: 3.1.0")) + assert(schema.contains("Simple Split API")) + } +}