From 0b929191d0c6e2083221e79cd45ef752e0a9dce9 Mon Sep 17 00:00:00 2001 From: Aliaksei Ivanouski Date: Fri, 24 Jul 2026 20:49:03 +0200 Subject: [PATCH 1/6] [BACKEND] Migrate from Postrgres to SQLite --- backend/Dockerfile | 2 +- .../scala/com/github/ai/split/Layers.scala | 12 +- .../github/ai/split/data/db/AppDatabase.scala | 42 +++--- .../data/db/DatabaseConnectionFactory.scala | 132 +++++------------- .../split/data/db/dao/CurrencyEntityDao.scala | 2 +- .../com/github/ai/split/data/db/dao/Dao.scala | 2 +- .../split/data/db/dao/ExpenseEntityDao.scala | 2 +- .../ai/split/data/db/dao/GroupEntityDao.scala | 2 +- .../data/db/dao/GroupMemberEntityDao.scala | 2 +- .../split/data/db/dao/PaidByEntityDao.scala | 2 +- .../data/db/dao/SplitBetweenEntityDao.scala | 2 +- .../ai/split/data/db/dao/UserEntityDao.scala | 2 +- .../data/db/model/DatabaseConnection.scala | 7 - backend/build.sbt | 6 +- backend/docker-compose.yaml | 27 +--- 15 files changed, 75 insertions(+), 169 deletions(-) delete mode 100644 backend/app/src/main/scala/com/github/ai/split/data/db/model/DatabaseConnection.scala diff --git a/backend/Dockerfile b/backend/Dockerfile index cbd7d68..c726902 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"] 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..8b11219 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 @@ -40,6 +40,7 @@ import com.github.ai.split.domain.usecases.{ ValidateExpenseUseCase, ValidateMemberNameUseCase } +import com.github.ai.split.entity.CliArguments import com.github.ai.split.presentation.controllers.{ CurrencyController, ExpenseController, @@ -47,14 +48,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 arguments = ZIO.service[CliArguments].run + val db = DatabaseConnectionFactory().create(arguments.isUseInMemoryDatabase).run + AppDatabase(db) + } } // Dao's 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/DatabaseConnectionFactory.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/DatabaseConnectionFactory.scala index 9d34678..487f1ec 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,55 @@ 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 com.github.ai.split.utils.toDomainError +import com.zaxxer.hikari.{HikariConfig, HikariDataSource} +import io.github.cdimascio.dotenv.Dotenv +import slick.jdbc.SQLiteProfile.api.* +import zio.direct.* +import zio.{Scope, ZIO} -import java.io.File -import scala.io.Source +import java.nio.file.Files +import java.nio.file.Path 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 + def create(useInMemoryDatabase: Boolean): ZIO[Scope, DomainError, Database] = defer { + val databaseUrl = + if (useInMemoryDatabase) { + "jdbc:sqlite::memory:" } else { - ZIO.fail(DomainError(message = "Database connection is not specified".some)).run + val dotenv = Dotenv.configure().ignoreIfMissing().load() + sys.env + .get(DatabaseConnectionFactory.DatabaseUrl) + .orElse(Option(dotenv.get(DatabaseConnectionFactory.DatabaseUrl))) + .getOrElse(DatabaseConnectionFactory.DefaultDatabaseUrl) } - } - } - - 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 + val db = ZIO.acquireRelease { + ZIO + .attempt { + if (databaseUrl == DatabaseConnectionFactory.DefaultDatabaseUrl) { + Files.createDirectories(Path.of("app-data", "db")) + } + + val hikariConfig = HikariConfig() + hikariConfig.setJdbcUrl(databaseUrl) + hikariConfig.setDriverClassName("org.sqlite.JDBC") + hikariConfig.setMaximumPoolSize(if (useInMemoryDatabase) 1 else 4) + hikariConfig.setMinimumIdle(1) + hikariConfig.setConnectionInitSql("PRAGMA foreign_keys = ON") + + val dataSource = HikariDataSource(hikariConfig) + Database.forDataSource(dataSource, Some(hikariConfig.getMaximumPoolSize)) } - } - .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" + val DatabaseUrl = "DATABASE_URL" + val DefaultDatabaseUrl = "jdbc:sqlite:./app-data/db/simple-split.sqlite?journal_mode=WAL&busy_timeout=30000" } 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/build.sbt b/backend/build.sbt index 42177a6..592d835 100644 --- a/backend/build.sbt +++ b/backend/build.sbt @@ -46,6 +46,7 @@ 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", @@ -56,10 +57,9 @@ lazy val app = project "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", @@ -83,4 +83,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..9565eef 100644 --- a/backend/docker-compose.yaml +++ b/backend/docker-compose.yaml @@ -1,40 +1,15 @@ 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} + - DATABASE_URL=jdbc:sqlite:/app-data/db/simple-split.sqlite?journal_mode=WAL&busy_timeout=30000 volumes: - ss-app-data:/app-data volumes: - ss-db: - driver: local ss-app-data: driver: local From e0ba0fdaf6ec4248e3b8dc06fe9d1c67be30b0dd Mon Sep 17 00:00:00 2001 From: Aliaksei Ivanouski Date: Fri, 24 Jul 2026 20:54:31 +0200 Subject: [PATCH 2/6] [BACKEND] Fix logging --- backend/app/src/main/resources/logback.xml | 16 +++---- .../main/scala/com/github/ai/split/Main.scala | 28 +++++++++--- .../ai/split/utils/AnsiStrippingEncoder.scala | 22 ++++++++++ .../github/ai/split/utils/RequestLogger.scala | 44 +++++++++++++++++++ backend/build.sbt | 2 +- 5 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 backend/app/src/main/scala/com/github/ai/split/utils/AnsiStrippingEncoder.scala create mode 100644 backend/app/src/main/scala/com/github/ai/split/utils/RequestLogger.scala 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/Main.scala b/backend/app/src/main/scala/com/github/ai/split/Main.scala index 6e37c0c..c8ebe72 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 @@ -7,22 +7,36 @@ import com.github.ai.split.domain.usecases.{FillTestDataUseCase, StartUpServerUs import com.github.ai.split.entity.CliArguments 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.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 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()) + @@ 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 { 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/RequestLogger.scala b/backend/app/src/main/scala/com/github/ai/split/utils/RequestLogger.scala new file mode 100644 index 0000000..db3428a --- /dev/null +++ b/backend/app/src/main/scala/com/github/ai/split/utils/RequestLogger.scala @@ -0,0 +1,44 @@ +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/build.sbt b/backend/build.sbt index 592d835..3f59951 100644 --- a/backend/build.sbt +++ b/backend/build.sbt @@ -51,7 +51,7 @@ lazy val app = project // 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", From b355bb1352392ad59e0ed6bf3a765e8576abcc2f Mon Sep 17 00:00:00 2001 From: Aliaksei Ivanouski Date: Fri, 24 Jul 2026 21:15:24 +0200 Subject: [PATCH 3/6] [BACKEND] Introduced OpenAPI schema --- backend/README.md | 18 + .../github/ai/split/client/ApiClient.scala | 46 +- .../com/github/ai/split/api/CurrencyDto.java | 16 - .../github/ai/split/api/ErrorMessageDto.java | 22 - .../com/github/ai/split/api/ExpenseDto.java | 37 - .../github/ai/split/api/GetGroupErrorDto.java | 14 - .../com/github/ai/split/api/GroupDto.java | 38 - .../com/github/ai/split/api/MemberDto.java | 14 - .../github/ai/split/api/NewExpenseDto.java | 32 - .../com/github/ai/split/api/TimestampDto.java | 14 - .../github/ai/split/api/TransactionDto.java | 16 - .../com/github/ai/split/api/UserNameDto.java | 13 - .../com/github/ai/split/api/UserUidDto.java | 12 - .../split/api/request/PostExpenseRequest.java | 35 - .../split/api/request/PostGroupRequest.java | 33 - .../split/api/request/PostMemberRequest.java | 15 - .../split/api/request/PutExpenseRequest.java | 33 - .../ai/split/api/request/PutGroupRequest.java | 29 - .../split/api/request/PutMemberRequest.java | 12 - .../api/response/DeleteExpenseResponse.java | 13 - .../api/response/DeleteMemberResponse.java | 13 - .../api/response/GetCurrenciesResponse.java | 21 - .../split/api/response/GetGroupsResponse.java | 23 - .../api/response/PostExpenseResponse.java | 14 - .../split/api/response/PostGroupResponse.java | 13 - .../api/response/PostMemberResponse.java | 14 - .../api/response/PutExpenseResponse.java | 13 - .../split/api/response/PutGroupResponse.java | 13 - .../split/api/response/PutMemberResponse.java | 13 - .../scala/com/github/ai/split/api/Dto.scala | 72 ++ .../ai/split/api/request/Requests.scala | 46 + .../ai/split/api/response/Responses.scala | 19 + .../scala/com/github/ai/split/Layers.scala | 10 +- .../main/scala/com/github/ai/split/Main.scala | 7 +- .../github/ai/split/data/JsonSerializer.scala | 30 - .../controllers/CurrencyController.scala | 27 +- .../controllers/ExpenseController.scala | 54 +- .../controllers/GroupController.scala | 80 +- .../controllers/MemberController.scala | 35 +- .../presentation/routes/CurrencyRoutes.scala | 12 +- .../presentation/routes/ExpenseRoutes.scala | 30 +- .../presentation/routes/GroupRoutes.scala | 34 +- .../presentation/routes/MemberRoutes.scala | 32 +- .../ai/split/utils/CollectionExtensions.scala | 25 - .../ai/split/utils/DataConverters.scala | 10 +- .../github/ai/split/utils/RequestLogger.scala | 4 +- .../ai/split/utils/ResponseExtensions.scala | 17 +- backend/build.sbt | 36 +- backend/openapi-schema/openapi.yaml | 977 ++++++++++++++++++ .../ai/split/openapi/ApiEndpoints.scala | 119 +++ .../ai/split/openapi/GenerateOpenApi.scala | 26 + .../com/github/ai/split/openapi/Schemas.scala | 38 + .../ai/split/openapi/ApiEndpointsSuite.scala | 18 + 53 files changed, 1541 insertions(+), 816 deletions(-) create mode 100644 backend/README.md delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/CurrencyDto.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/ErrorMessageDto.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/ExpenseDto.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/GetGroupErrorDto.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/GroupDto.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/MemberDto.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/NewExpenseDto.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/TimestampDto.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/TransactionDto.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/UserNameDto.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/UserUidDto.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/request/PostExpenseRequest.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/request/PostGroupRequest.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/request/PostMemberRequest.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/request/PutExpenseRequest.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/request/PutGroupRequest.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/request/PutMemberRequest.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/response/DeleteExpenseResponse.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/response/DeleteMemberResponse.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/response/GetCurrenciesResponse.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/response/GetGroupsResponse.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/response/PostExpenseResponse.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/response/PostGroupResponse.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/response/PostMemberResponse.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/response/PutExpenseResponse.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/response/PutGroupResponse.java delete mode 100644 backend/api/src/main/java/com/github/ai/split/api/response/PutMemberResponse.java create mode 100644 backend/api/src/main/scala/com/github/ai/split/api/Dto.scala create mode 100644 backend/api/src/main/scala/com/github/ai/split/api/request/Requests.scala create mode 100644 backend/api/src/main/scala/com/github/ai/split/api/response/Responses.scala delete mode 100644 backend/app/src/main/scala/com/github/ai/split/data/JsonSerializer.scala delete mode 100644 backend/app/src/main/scala/com/github/ai/split/utils/CollectionExtensions.scala create mode 100644 backend/openapi-schema/openapi.yaml create mode 100644 backend/openapi-schema/src/main/scala/com/github/ai/split/openapi/ApiEndpoints.scala create mode 100644 backend/openapi-schema/src/main/scala/com/github/ai/split/openapi/GenerateOpenApi.scala create mode 100644 backend/openapi-schema/src/main/scala/com/github/ai/split/openapi/Schemas.scala create mode 100644 backend/openapi-schema/src/test/scala/com/github/ai/split/openapi/ApiEndpointsSuite.scala diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..f19380a --- /dev/null +++ b/backend/README.md @@ -0,0 +1,18 @@ +# Backend + +### Generate openapi.yaml file +``` +sbt generateOpenApi +``` + +### Run backend app +``` +sbt app/assembly +java -jar ./app/target/.../simple-split-backend.jar +``` + +### OpenAPI UI +OpenAPI UI is available here (the backend/app should be running): +``` +https://127.0.0.1:8443/docs/openapi +``` \ No newline at end of file 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..069fa6a --- /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 = 0L, + 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 = 0.0 +) + +final case class ExpenseDto( + uid: String = "", + title: String = "", + description: String = "", + amount: Double = 0.0, + currency: CurrencyDto = CurrencyDto(), + paidBy: List[MemberDto] = Nil, + splitBetween: List[MemberDto] = Nil, + created: TimestampDto = TimestampDto(), + modified: TimestampDto = TimestampDto() +) + +final case class NewExpenseDto( + title: String = "", + description: String = "", + amount: Double = 0.0, + paidBy: List[UserNameDto] = Nil, + isSplitBetweenAll: Option[Boolean] = None, + splitBetween: List[UserNameDto] = Nil +) + +final case class GroupDto( + uid: String = "", + title: String = "", + description: String = "", + currency: CurrencyDto = CurrencyDto(), + members: List[MemberDto] = Nil, + expenses: List[ExpenseDto] = Nil, + paybackTransactions: List[TransactionDto] = Nil, + created: TimestampDto = TimestampDto(), + modified: TimestampDto = TimestampDto() +) + +final case class GetGroupErrorDto( + uid: String = "", + message: String = "" +) + +final case class ErrorMessageDto( + message: Option[String] = None, + exception: String = "", + stacktraceBase64: String = "", + stacktraceLines: List[String] = Nil +) 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..3b1ff2d --- /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] = Nil, + expenses: List[NewExpenseDto] = Nil +) + +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 = 0.0, + paidBy: List[UserUidDto] = Nil, + isSplitBetweenAll: Option[Boolean] = None, + splitBetween: List[UserUidDto] = Nil +) + +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..da8a400 --- /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] = Nil) + +final case class GetGroupsResponse( + groups: List[GroupDto] = Nil, + errors: List[GetGroupErrorDto] = Nil +) + +final case class PostGroupResponse(group: GroupDto = GroupDto()) +final case class PutGroupResponse(group: GroupDto = GroupDto()) +final case class PostMemberResponse(group: GroupDto = GroupDto()) +final case class PutMemberResponse(group: GroupDto = GroupDto()) +final case class DeleteMemberResponse(group: GroupDto = GroupDto()) +final case class PostExpenseResponse(expense: ExpenseDto = ExpenseDto()) +final case class PutExpenseResponse(expense: ExpenseDto = ExpenseDto()) +final case class DeleteExpenseResponse(group: GroupDto = GroupDto()) 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 8b11219..1a6af60 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.{ @@ -108,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 c8ebe72..8ff50db 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 @@ -7,12 +7,15 @@ import com.github.ai.split.domain.usecases.{FillTestDataUseCase, StartUpServerUs import com.github.ai.split.entity.CliArguments 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.{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 @@ -23,7 +26,8 @@ object Main extends ZIOAppDefault { ++ ExportRoutes.routes() ++ MemberRoutes.routes() ++ ExpenseRoutes.routes() - ++ CurrencyRoutes.routes()) + ++ CurrencyRoutes.routes() + ++ SwaggerUI.routes("docs" / "openapi", ApiEndpoints.openApi)) @@ RequestLogger.requestLogger override val bootstrap: ZLayer[Any, Nothing, Unit] = { @@ -134,7 +138,6 @@ object Main extends ZIOAppDefault { // Others Layers.currencyParser, - Layers.jsonSerialized, Server.live, ZLayer.succeed(serverConfig) ) 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/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/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 index db3428a..c3abd6c 100644 --- 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 @@ -16,9 +16,7 @@ object RequestLogger { val response = requestHandler .apply(request) - .tapError(err => - ZIO.logError(s"<- ${request.method} ${request.url.encode} error=$err") - ) + .tapError(err => ZIO.logError(s"<- ${request.method} ${request.url.encode} error=$err")) .run val endTime = Clock.nanoTime.run 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 3f59951..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 := { @@ -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 := { diff --git a/backend/openapi-schema/openapi.yaml b/backend/openapi-schema/openapi.yaml new file mode 100644 index 0000000..1d9bb5f --- /dev/null +++ b/backend/openapi-schema/openapi.yaml @@ -0,0 +1,977 @@ +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': + content: + application/json: + schema: + $ref: '#/components/schemas/PostExpenseResponse' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessageDto' + /currency: + description: | + - currencies + get: + tags: + - currencies + description: | + - currencies + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/GetCurrenciesResponse' + '400': + 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': + content: + application/json: + schema: + $ref: '#/components/schemas/PutMemberResponse' + '400': + 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': + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteMemberResponse' + '400': + 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': + content: + text/csv: + schema: + type: string + '400': + 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': + content: + application/json: + schema: + $ref: '#/components/schemas/PutExpenseResponse' + '400': + 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': + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteExpenseResponse' + '400': + 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': + content: + application/json: + schema: + $ref: '#/components/schemas/PostMemberResponse' + '400': + 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': + content: + application/json: + schema: + $ref: '#/components/schemas/GetGroupsResponse' + '400': + 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': + content: + application/json: + schema: + $ref: '#/components/schemas/PostGroupResponse' + '400': + 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': + content: + application/json: + schema: + $ref: '#/components/schemas/PutGroupResponse' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessageDto' +components: + schemas: + GroupDto: + type: object + properties: + description: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + paybackTransactions: + type: array + items: + $ref: '#/components/schemas/TransactionDto' + description: If not set, this field defaults to the value of the default annotation. + default: [ + ] + modified: + $ref: '#/components/schemas/TimestampDto' + description: If not set, this field defaults to the value of the default annotation. + default: + timestampSeconds: 0 + formatted: '' + members: + type: array + items: + $ref: '#/components/schemas/MemberDto' + description: If not set, this field defaults to the value of the default annotation. + default: [ + ] + title: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + expenses: + type: array + items: + $ref: '#/components/schemas/ExpenseDto' + description: If not set, this field defaults to the value of the default annotation. + default: [ + ] + created: + $ref: '#/components/schemas/TimestampDto' + description: If not set, this field defaults to the value of the default annotation. + default: + timestampSeconds: 0 + formatted: '' + currency: + $ref: '#/components/schemas/CurrencyDto' + description: If not set, this field defaults to the value of the default annotation. + default: + isoCode: '' + name: '' + symbol: '' + uid: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + PostMemberRequest: + type: object + properties: + groupUid: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + name: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + GetCurrenciesResponse: + type: object + properties: + currencies: + type: array + items: + $ref: '#/components/schemas/CurrencyDto' + description: If not set, this field defaults to the value of the default annotation. + default: [ + ] + UserUidDto: + type: object + properties: + uid: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + PostGroupResponse: + type: object + properties: + group: + $ref: '#/components/schemas/GroupDto' + description: If not set, this field defaults to the value of the default annotation. + default: + uid: '' + title: '' + description: '' + currency: + isoCode: '' + name: '' + symbol: '' + created: + timestampSeconds: 0 + formatted: '' + modified: + timestampSeconds: 0 + formatted: '' + 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' + description: If not set, this field defaults to the value of the default annotation. + default: + uid: '' + title: '' + description: '' + amount: 0.0 + currency: + isoCode: '' + name: '' + symbol: '' + created: + timestampSeconds: 0 + formatted: '' + modified: + timestampSeconds: 0 + formatted: '' + PutMemberResponse: + type: object + properties: + group: + $ref: '#/components/schemas/GroupDto' + description: If not set, this field defaults to the value of the default annotation. + default: + uid: '' + title: '' + description: '' + currency: + isoCode: '' + name: '' + symbol: '' + created: + timestampSeconds: 0 + formatted: '' + modified: + timestampSeconds: 0 + formatted: '' + PostExpenseRequest: + 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 + description: If not set, this field defaults to the value of the default annotation. + default: '' + 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 + description: If not set, this field defaults to the value of the default annotation. + default: '' + groupUid: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + amount: + type: number + format: double + description: If not set, this field defaults to the value of the default annotation. + default: 0.0 + NewExpenseDto: + type: object + properties: + splitBetween: + type: array + items: + $ref: '#/components/schemas/UserNameDto' + description: If not set, this field defaults to the value of the default annotation. + default: [ + ] + description: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + amount: + type: number + format: double + description: If not set, this field defaults to the value of the default annotation. + default: 0.0 + 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/UserNameDto' + description: If not set, this field defaults to the value of the default annotation. + default: [ + ] + title: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + UserNameDto: + type: object + properties: + name: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + GetGroupsResponse: + type: object + properties: + groups: + type: array + items: + $ref: '#/components/schemas/GroupDto' + description: If not set, this field defaults to the value of the default annotation. + default: [ + ] + errors: + type: array + items: + $ref: '#/components/schemas/GetGroupErrorDto' + description: If not set, this field defaults to the value of the default annotation. + default: [ + ] + DeleteExpenseResponse: + type: object + properties: + group: + $ref: '#/components/schemas/GroupDto' + description: If not set, this field defaults to the value of the default annotation. + default: + uid: '' + title: '' + description: '' + currency: + isoCode: '' + name: '' + symbol: '' + created: + timestampSeconds: 0 + formatted: '' + modified: + timestampSeconds: 0 + formatted: '' + CurrencyDto: + type: object + properties: + isoCode: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + name: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + symbol: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + ExpenseDto: + type: object + properties: + splitBetween: + type: array + items: + $ref: '#/components/schemas/MemberDto' + description: If not set, this field defaults to the value of the default annotation. + default: [ + ] + description: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + amount: + type: number + format: double + description: If not set, this field defaults to the value of the default annotation. + default: 0.0 + modified: + $ref: '#/components/schemas/TimestampDto' + description: If not set, this field defaults to the value of the default annotation. + default: + timestampSeconds: 0 + formatted: '' + paidBy: + type: array + items: + $ref: '#/components/schemas/MemberDto' + description: If not set, this field defaults to the value of the default annotation. + default: [ + ] + title: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + created: + $ref: '#/components/schemas/TimestampDto' + description: If not set, this field defaults to the value of the default annotation. + default: + timestampSeconds: 0 + formatted: '' + currency: + $ref: '#/components/schemas/CurrencyDto' + description: If not set, this field defaults to the value of the default annotation. + default: + isoCode: '' + name: '' + symbol: '' + uid: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + 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' + description: If not set, this field defaults to the value of the default annotation. + default: + uid: '' + title: '' + description: '' + currency: + isoCode: '' + name: '' + symbol: '' + created: + timestampSeconds: 0 + formatted: '' + modified: + timestampSeconds: 0 + formatted: '' + PutExpenseResponse: + type: object + properties: + expense: + $ref: '#/components/schemas/ExpenseDto' + description: If not set, this field defaults to the value of the default annotation. + default: + uid: '' + title: '' + description: '' + amount: 0.0 + currency: + isoCode: '' + name: '' + symbol: '' + created: + timestampSeconds: 0 + formatted: '' + modified: + timestampSeconds: 0 + formatted: '' + TransactionDto: + type: object + properties: + creditorUid: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + debtorUid: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + amount: + type: number + format: double + description: If not set, this field defaults to the value of the default annotation. + default: 0.0 + PostGroupRequest: + type: object + properties: + currencyIsoCode: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + description: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + members: + type: array + items: + $ref: '#/components/schemas/UserNameDto' + description: If not set, this field defaults to the value of the default annotation. + default: [ + ] + title: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + expenses: + type: array + items: + $ref: '#/components/schemas/NewExpenseDto' + description: If not set, this field defaults to the value of the default annotation. + default: [ + ] + password: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + PutGroupResponse: + type: object + properties: + group: + $ref: '#/components/schemas/GroupDto' + description: If not set, this field defaults to the value of the default annotation. + default: + uid: '' + title: '' + description: '' + currency: + isoCode: '' + name: '' + symbol: '' + created: + timestampSeconds: 0 + formatted: '' + modified: + timestampSeconds: 0 + formatted: '' + MemberDto: + type: object + properties: + uid: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + name: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + TimestampDto: + type: object + properties: + timestampSeconds: + type: integer + format: int64 + description: If not set, this field defaults to the value of the default annotation. + default: 0 + formatted: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + GetGroupErrorDto: + type: object + properties: + uid: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + message: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + ErrorMessageDto: + type: object + properties: + message: + type: + - string + - 'null' + description: If not set, this field defaults to the value of the default annotation. + default: null + exception: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + stacktraceBase64: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' + stacktraceLines: + type: array + items: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: [ + ] + DeleteMemberResponse: + type: object + properties: + group: + $ref: '#/components/schemas/GroupDto' + description: If not set, this field defaults to the value of the default annotation. + default: + uid: '' + title: '' + description: '' + currency: + isoCode: '' + name: '' + symbol: '' + created: + timestampSeconds: 0 + formatted: '' + modified: + timestampSeconds: 0 + formatted: '' + PutMemberRequest: + type: object + properties: + name: + type: string + description: If not set, this field defaults to the value of the default annotation. + default: '' 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..c4085ee --- /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.{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) + + val getGroups = + endpoint(Method.GET / "group") + .query(QueryCodec.query[String]("ids")) + .query(QueryCodec.query[String]("passwords")) + .out[GetGroupsResponse] + .tag("groups") + + val postGroup = + endpoint(Method.POST / "group") + .in[PostGroupRequest] + .out[PostGroupResponse] + .tag("groups") + + val putGroup = + endpoint(Method.PUT / "group" / string("groupId")) + .query(QueryCodec.query[String]("password").optional) + .in[PutGroupRequest] + .out[PutGroupResponse] + .tag("groups") + + val postMember = + endpoint(Method.POST / "member") + .query(QueryCodec.query[String]("password").optional) + .in[PostMemberRequest] + .out[PostMemberResponse] + .tag("members") + + val putMember = + endpoint(Method.PUT / "member" / string("memberId")) + .query(QueryCodec.query[String]("password").optional) + .in[PutMemberRequest] + .out[PutMemberResponse] + .tag("members") + + val deleteMember = + endpoint(Method.DELETE / "member" / string("memberId")) + .query(QueryCodec.query[String]("password").optional) + .out[DeleteMemberResponse] + .tag("members") + + val postExpense = + endpoint(Method.POST / "expense") + .query(QueryCodec.query[String]("password").optional) + .in[PostExpenseRequest] + .out[PostExpenseResponse] + .tag("expenses") + + val putExpense = + endpoint(Method.PUT / "expense" / string("expenseId")) + .query(QueryCodec.query[String]("password").optional) + .in[PutExpenseRequest] + .out[PutExpenseResponse] + .tag("expenses") + + val deleteExpense = + endpoint(Method.DELETE / "expense" / string("expenseId")) + .query(QueryCodec.query[String]("password").optional) + .out[DeleteExpenseResponse] + .tag("expenses") + + val getCurrencies = + endpoint(Method.GET / "currency") + .out[GetCurrenciesResponse] + .tag("currencies") + + val exportGroup = + endpoint(Method.GET / "export" / string("groupIdAndExtension")) + .query(QueryCodec.query[String]("password").optional) + .out[String](MediaType.text.csv) + .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")) + } +} From 8e16361610313d3074c1030e2e6fff94817fc6b2 Mon Sep 17 00:00:00 2001 From: Aliaksei Ivanouski Date: Fri, 24 Jul 2026 21:45:57 +0200 Subject: [PATCH 4/6] [BACKEND] Add .env file handling --- .gitignore | 2 +- backend/.env.debug | 4 - backend/.env.example | 17 ++ backend/Dockerfile | 2 +- backend/README.md | 6 +- .../scala/com/github/ai/split/Layers.scala | 6 +- .../main/scala/com/github/ai/split/Main.scala | 174 +++++++++--------- .../ai/split/data/db/DatabaseConfig.scala | 7 + .../data/db/DatabaseConnectionFactory.scala | 35 +--- .../domain/ApplicationConfigLoader.scala | 72 ++++++++ .../ai/split/domain/CliArgumentParser.scala | 72 -------- .../usecases/StartUpServerUseCase.scala | 6 +- .../ai/split/entity/ApplicationConfig.scala | 15 ++ .../github/ai/split/entity/CliArguments.scala | 16 -- .../split/entity/exception/DomainError.scala | 10 + backend/docker-compose.yaml | 6 + 16 files changed, 239 insertions(+), 211 deletions(-) delete mode 100644 backend/.env.debug create mode 100644 backend/.env.example create mode 100644 backend/app/src/main/scala/com/github/ai/split/data/db/DatabaseConfig.scala create mode 100644 backend/app/src/main/scala/com/github/ai/split/domain/ApplicationConfigLoader.scala delete mode 100644 backend/app/src/main/scala/com/github/ai/split/domain/CliArgumentParser.scala create mode 100644 backend/app/src/main/scala/com/github/ai/split/entity/ApplicationConfig.scala delete mode 100644 backend/app/src/main/scala/com/github/ai/split/entity/CliArguments.scala 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/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 c726902..459bbc3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,4 +8,4 @@ FROM openjdk:17 EXPOSE 8080 8443 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 index f19380a..30f8e69 100644 --- a/backend/README.md +++ b/backend/README.md @@ -6,13 +6,17 @@ 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 -``` \ 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 1a6af60..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 @@ -39,7 +39,7 @@ import com.github.ai.split.domain.usecases.{ ValidateExpenseUseCase, ValidateMemberNameUseCase } -import com.github.ai.split.entity.CliArguments +import com.github.ai.split.entity.ApplicationConfig import com.github.ai.split.presentation.controllers.{ CurrencyController, ExpenseController, @@ -54,8 +54,8 @@ object Layers { // Database val appDatabase = ZLayer.scoped { defer { - val arguments = ZIO.service[CliArguments].run - val db = DatabaseConnectionFactory().create(arguments.isUseInMemoryDatabase).run + val config = ZIO.service[ApplicationConfig].run + val db = DatabaseConnectionFactory(config.database).create().run AppDatabase(db) } } 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 8ff50db..ff0b4ea 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,9 +2,9 @@ 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 @@ -36,8 +36,8 @@ object Main extends ZIOAppDefault { .timestamp(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssAZ")) .highlight(_ => LogColor.BLUE) |-| LogFormat.bracketStart + LogFormat.loggerName( - LoggerNameExtractor.trace - ) + LogFormat.bracketEnd |-| + LoggerNameExtractor.trace + ) + LogFormat.bracketEnd |-| LogFormat.fiberId |-| LogFormat.level.highlight |-| LogFormat.line.highlight Runtime.removeDefaultLoggers >>> SLF4J.slf4j(logFormat) @@ -53,94 +53,100 @@ 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}") - - serverConfig <- createServerConfig(arguments) - - _ <- application().provide( - // Application arguments - ZLayer.succeed(arguments), - - // Use-Cases - Layers.addUserUseCase, - Layers.getAllUsersUseCase, - Layers.addGroupUseCase, - Layers.getGroupByUidUseCase, - Layers.addMemberUseCase, - Layers.addExpenseUseCase, - Layers.convertToTransactionsUseCase, - Layers.calculateSettlementUseCase, - Layers.fillTestDataUseCase, - Layers.updateGroupUseCase, - Layers.updateExpenseUseCase, - Layers.removeMembersUseCase, - Layers.resolveUserReferencesUseCase, - Layers.validateMemberNameUseCase, - Layers.validateExpenseUseCase, - Layers.removeExpenseUseCase, - Layers.exportGroupDataUseCase, - Layers.updateMemberUseCase, - Layers.startUpServerUseCase, - Layers.fillCurrencyDataUseCase, - Layers.validateCurrencyUseCase, - - // Response assemblers use cases - Layers.assembleGroupResponseUseCase, - Layers.assembleGroupsResponseUseCase, - Layers.assembleExpenseUseCase, - - // Controllers - Layers.memberController, - Layers.groupController, - Layers.expenseController, - Layers.currencyController, - - // Services - Layers.passwordService, - Layers.accessResolverService, - - // Database - Layers.appDatabase, - - // Repositories - Layers.expenseRepository, - Layers.groupRepository, - Layers.currencyRepository, - - // Dao - Layers.expenseDao, - Layers.groupDao, - Layers.groupMemberDao, - Layers.userDao, - Layers.paidByDao, - Layers.splitBetweenDao, - Layers.currencyDao, - - // Others - Layers.currencyParser, - Server.live, - ZLayer.succeed(serverConfig) - ) - } yield () + 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 + + val serverConfig = createServerConfig(config).run + + application().provide( + // Application config + ZLayer.succeed(config), + + // Use-Cases + Layers.addUserUseCase, + Layers.getAllUsersUseCase, + Layers.addGroupUseCase, + Layers.getGroupByUidUseCase, + Layers.addMemberUseCase, + Layers.addExpenseUseCase, + Layers.convertToTransactionsUseCase, + Layers.calculateSettlementUseCase, + Layers.fillTestDataUseCase, + Layers.updateGroupUseCase, + Layers.updateExpenseUseCase, + Layers.removeMembersUseCase, + Layers.resolveUserReferencesUseCase, + Layers.validateMemberNameUseCase, + Layers.validateExpenseUseCase, + Layers.removeExpenseUseCase, + Layers.exportGroupDataUseCase, + Layers.updateMemberUseCase, + Layers.startUpServerUseCase, + Layers.fillCurrencyDataUseCase, + Layers.validateCurrencyUseCase, + + // Response assemblers use cases + Layers.assembleGroupResponseUseCase, + Layers.assembleGroupsResponseUseCase, + Layers.assembleExpenseUseCase, + + // Controllers + Layers.memberController, + Layers.groupController, + Layers.expenseController, + Layers.currencyController, + + // Services + Layers.passwordService, + Layers.accessResolverService, + + // Database + Layers.appDatabase, + + // Repositories + Layers.expenseRepository, + Layers.groupRepository, + Layers.currencyRepository, + + // Dao + Layers.expenseDao, + Layers.groupDao, + Layers.groupMemberDao, + Layers.userDao, + Layers.paidByDao, + Layers.splitBetweenDao, + Layers.currencyDao, + + // Others + Layers.currencyParser, + Server.live, + ZLayer.succeed(serverConfig) + ).run + () } } 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 487f1ec..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 @@ -3,7 +3,6 @@ package com.github.ai.split.data.db import com.github.ai.split.entity.exception.DomainError import com.github.ai.split.utils.toDomainError import com.zaxxer.hikari.{HikariConfig, HikariDataSource} -import io.github.cdimascio.dotenv.Dotenv import slick.jdbc.SQLiteProfile.api.* import zio.direct.* import zio.{Scope, ZIO} @@ -11,36 +10,25 @@ import zio.{Scope, ZIO} import java.nio.file.Files import java.nio.file.Path -class DatabaseConnectionFactory { - - def create(useInMemoryDatabase: Boolean): ZIO[Scope, DomainError, Database] = defer { - val databaseUrl = - if (useInMemoryDatabase) { - "jdbc:sqlite::memory:" - } else { - val dotenv = Dotenv.configure().ignoreIfMissing().load() - sys.env - .get(DatabaseConnectionFactory.DatabaseUrl) - .orElse(Option(dotenv.get(DatabaseConnectionFactory.DatabaseUrl))) - .getOrElse(DatabaseConnectionFactory.DefaultDatabaseUrl) - } +class DatabaseConnectionFactory( + private val config: DatabaseConfig +) { + def create(): ZIO[Scope, DomainError, Database] = defer { val db = ZIO.acquireRelease { ZIO .attempt { - if (databaseUrl == DatabaseConnectionFactory.DefaultDatabaseUrl) { - Files.createDirectories(Path.of("app-data", "db")) - } + Files.createDirectories(Path.of("app-data", "db")) val hikariConfig = HikariConfig() - hikariConfig.setJdbcUrl(databaseUrl) + hikariConfig.setJdbcUrl(config.url) hikariConfig.setDriverClassName("org.sqlite.JDBC") - hikariConfig.setMaximumPoolSize(if (useInMemoryDatabase) 1 else 4) - hikariConfig.setMinimumIdle(1) + hikariConfig.setMaximumPoolSize(config.maximumPoolSize) + hikariConfig.setMinimumIdle(config.minimumIdle) hikariConfig.setConnectionInitSql("PRAGMA foreign_keys = ON") val dataSource = HikariDataSource(hikariConfig) - Database.forDataSource(dataSource, Some(hikariConfig.getMaximumPoolSize)) + Database.forDataSource(dataSource, Some(config.maximumPoolSize)) } .mapError(_.toDomainError()) }(db => ZIO.attempt(db.close()).orDie).run @@ -48,8 +36,3 @@ class DatabaseConnectionFactory { db } } - -object DatabaseConnectionFactory { - val DatabaseUrl = "DATABASE_URL" - val DefaultDatabaseUrl = "jdbc:sqlite:./app-data/db/simple-split.sqlite?journal_mode=WAL&busy_timeout=30000" -} 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..1dca09e --- /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/docker-compose.yaml b/backend/docker-compose.yaml index 9565eef..a091e27 100644 --- a/backend/docker-compose.yaml +++ b/backend/docker-compose.yaml @@ -6,7 +6,13 @@ services: - "4281:8080" - "4282:8443" environment: + - 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 From 6171917fdac40afd158bed5f08961463fd1db58f Mon Sep 17 00:00:00 2001 From: Aliaksei Ivanouski Date: Fri, 24 Jul 2026 22:07:12 +0200 Subject: [PATCH 5/6] [BACKEND] Fix code format --- .../main/scala/com/github/ai/split/Main.scala | 134 +++++++++--------- .../domain/ApplicationConfigLoader.scala | 2 +- 2 files changed, 69 insertions(+), 67 deletions(-) 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 ff0b4ea..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 @@ -36,8 +36,8 @@ object Main extends ZIOAppDefault { .timestamp(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssAZ")) .highlight(_ => LogColor.BLUE) |-| LogFormat.bracketStart + LogFormat.loggerName( - LoggerNameExtractor.trace - ) + LogFormat.bracketEnd |-| + LoggerNameExtractor.trace + ) + LogFormat.bracketEnd |-| LogFormat.fiberId |-| LogFormat.level.highlight |-| LogFormat.line.highlight Runtime.removeDefaultLoggers >>> SLF4J.slf4j(logFormat) @@ -83,70 +83,72 @@ object Main extends ZIOAppDefault { val serverConfig = createServerConfig(config).run - application().provide( - // Application config - ZLayer.succeed(config), - - // Use-Cases - Layers.addUserUseCase, - Layers.getAllUsersUseCase, - Layers.addGroupUseCase, - Layers.getGroupByUidUseCase, - Layers.addMemberUseCase, - Layers.addExpenseUseCase, - Layers.convertToTransactionsUseCase, - Layers.calculateSettlementUseCase, - Layers.fillTestDataUseCase, - Layers.updateGroupUseCase, - Layers.updateExpenseUseCase, - Layers.removeMembersUseCase, - Layers.resolveUserReferencesUseCase, - Layers.validateMemberNameUseCase, - Layers.validateExpenseUseCase, - Layers.removeExpenseUseCase, - Layers.exportGroupDataUseCase, - Layers.updateMemberUseCase, - Layers.startUpServerUseCase, - Layers.fillCurrencyDataUseCase, - Layers.validateCurrencyUseCase, - - // Response assemblers use cases - Layers.assembleGroupResponseUseCase, - Layers.assembleGroupsResponseUseCase, - Layers.assembleExpenseUseCase, - - // Controllers - Layers.memberController, - Layers.groupController, - Layers.expenseController, - Layers.currencyController, - - // Services - Layers.passwordService, - Layers.accessResolverService, - - // Database - Layers.appDatabase, - - // Repositories - Layers.expenseRepository, - Layers.groupRepository, - Layers.currencyRepository, - - // Dao - Layers.expenseDao, - Layers.groupDao, - Layers.groupMemberDao, - Layers.userDao, - Layers.paidByDao, - Layers.splitBetweenDao, - Layers.currencyDao, - - // Others - Layers.currencyParser, - Server.live, - ZLayer.succeed(serverConfig) - ).run + application() + .provide( + // Application config + ZLayer.succeed(config), + + // Use-Cases + Layers.addUserUseCase, + Layers.getAllUsersUseCase, + Layers.addGroupUseCase, + Layers.getGroupByUidUseCase, + Layers.addMemberUseCase, + Layers.addExpenseUseCase, + Layers.convertToTransactionsUseCase, + Layers.calculateSettlementUseCase, + Layers.fillTestDataUseCase, + Layers.updateGroupUseCase, + Layers.updateExpenseUseCase, + Layers.removeMembersUseCase, + Layers.resolveUserReferencesUseCase, + Layers.validateMemberNameUseCase, + Layers.validateExpenseUseCase, + Layers.removeExpenseUseCase, + Layers.exportGroupDataUseCase, + Layers.updateMemberUseCase, + Layers.startUpServerUseCase, + Layers.fillCurrencyDataUseCase, + Layers.validateCurrencyUseCase, + + // Response assemblers use cases + Layers.assembleGroupResponseUseCase, + Layers.assembleGroupsResponseUseCase, + Layers.assembleExpenseUseCase, + + // Controllers + Layers.memberController, + Layers.groupController, + Layers.expenseController, + Layers.currencyController, + + // Services + Layers.passwordService, + Layers.accessResolverService, + + // Database + Layers.appDatabase, + + // Repositories + Layers.expenseRepository, + Layers.groupRepository, + Layers.currencyRepository, + + // Dao + Layers.expenseDao, + Layers.groupDao, + Layers.groupMemberDao, + Layers.userDao, + Layers.paidByDao, + Layers.splitBetweenDao, + Layers.currencyDao, + + // Others + Layers.currencyParser, + Server.live, + ZLayer.succeed(serverConfig) + ) + .run () } } 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 index 1dca09e..97fa80c 100644 --- 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 @@ -56,7 +56,7 @@ class ApplicationConfigLoader { private def readBooleanRequired(dotenv: Dotenv, key: String): IO[EnvironmentError, Boolean] = defer { val value = readRequired(dotenv, key).run value.toLowerCase match { - case "true" => true + case "true" => true case "false" => false case _ => ZIO From e684365043c61e2a79bd45fda9ad53605c0037ec Mon Sep 17 00:00:00 2001 From: Aliaksei Ivanouski Date: Fri, 24 Jul 2026 22:39:13 +0200 Subject: [PATCH 6/6] [ANDROID] Integrate OpenAPI; Integrate ktlint --- .github/workflows/ci.yaml | 87 ++-- android/.editorconfig | 8 +- android/app/build.gradle.kts | 3 +- android/app/libs/simple-split-api.jar | Bin 17941 -> 0 bytes .../simplesplit/android/data/api/ApiClient.kt | 32 +- .../data/repository/ExpenseRepository.kt | 10 +- .../data/repository/GroupRepository.kt | 8 +- .../data/repository/MemberRepository.kt | 10 +- .../cells/model/BottomSheetHeaderCellEvent.kt | 4 +- .../compose/cells/model/ButtonCellEvent.kt | 4 +- .../core/compose/cells/model/MenuCellEvent.kt | 4 +- .../presentation/core/mvi/MviViewModel.kt | 3 +- .../model/SelectCurrencyDialogIntent.kt | 12 +- .../model/CheckoutGroupIntent.kt | 4 +- .../expenseEditor/ExpenseEditorInteractor.kt | 30 +- .../model/ExpenseEditorIntent.kt | 12 +- .../cells/model/ExpenseCellEvent.kt | 8 +- .../cells/model/SettlementCellEvent.kt | 4 +- .../groupDetails/model/GroupDetailsIntent.kt | 28 +- .../model/GroupDetailsNavEvent.kt | 8 +- .../groupEditor/GroupEditorInteractor.kt | 20 +- .../groupEditor/model/GroupEditorIntent.kt | 32 +- .../groupEditor/model/GroupEditorMode.kt | 4 +- .../groups/cells/model/GroupCellModel.kt | 8 +- .../screens/groups/model/GroupsIntent.kt | 32 +- .../screens/root/model/StartActivityEvent.kt | 8 +- .../screens/settings/model/SettingsState.kt | 8 +- .../ai/simplesplit/android/utils/CellId.kt | 8 +- .../simplesplit/android/utils/SingleFlow.kt | 4 +- android/backend-api/build.gradle.kts | 47 ++ android/build.gradle.kts | 12 +- android/gradle/libs.versions.toml | 6 +- android/settings.gradle.kts | 3 +- android/setup-api.sh | 27 -- .../scala/com/github/ai/split/api/Dto.scala | 84 ++-- .../ai/split/api/request/Requests.scala | 32 +- .../ai/split/api/response/Responses.scala | 22 +- backend/openapi-schema/openapi.yaml | 430 +++++++----------- .../ai/split/openapi/ApiEndpoints.scala | 26 +- 39 files changed, 572 insertions(+), 520 deletions(-) delete mode 100644 android/app/libs/simple-split-api.jar create mode 100644 android/backend-api/build.gradle.kts delete mode 100755 android/setup-api.sh 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/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 4967980f477310e84db5cae9ece52c95610778e9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17941 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 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/api/src/main/scala/com/github/ai/split/api/Dto.scala b/backend/api/src/main/scala/com/github/ai/split/api/Dto.scala index 069fa6a..50dd181 100644 --- 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 @@ -1,72 +1,72 @@ package com.github.ai.split.api final case class CurrencyDto( - isoCode: String = "", - name: String = "", - symbol: String = "" + isoCode: String, + name: String, + symbol: String ) final case class TimestampDto( - timestampSeconds: Long = 0L, - formatted: String = "" + timestampSeconds: Long, + formatted: String ) final case class MemberDto( - uid: String = "", - name: String = "" + uid: String, + name: String ) -final case class UserNameDto(name: String = "") +final case class UserNameDto(name: String) -final case class UserUidDto(uid: String = "") +final case class UserUidDto(uid: String) final case class TransactionDto( - creditorUid: String = "", - debtorUid: String = "", - amount: Double = 0.0 + creditorUid: String, + debtorUid: String, + amount: Double ) final case class ExpenseDto( - uid: String = "", - title: String = "", - description: String = "", - amount: Double = 0.0, - currency: CurrencyDto = CurrencyDto(), - paidBy: List[MemberDto] = Nil, - splitBetween: List[MemberDto] = Nil, - created: TimestampDto = TimestampDto(), - modified: TimestampDto = TimestampDto() + 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 = 0.0, - paidBy: List[UserNameDto] = Nil, - isSplitBetweenAll: Option[Boolean] = None, - splitBetween: List[UserNameDto] = Nil + 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 = CurrencyDto(), - members: List[MemberDto] = Nil, - expenses: List[ExpenseDto] = Nil, - paybackTransactions: List[TransactionDto] = Nil, - created: TimestampDto = TimestampDto(), - modified: TimestampDto = TimestampDto() + 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 = "" + uid: String, + message: String ) final case class ErrorMessageDto( - message: Option[String] = None, - exception: String = "", - stacktraceBase64: String = "", - stacktraceLines: List[String] = Nil + 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 index 3b1ff2d..f221dfd 100644 --- 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 @@ -3,12 +3,12 @@ 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] = Nil, - expenses: List[NewExpenseDto] = Nil + password: String, + title: String, + description: String, + currencyIsoCode: String, + members: List[UserNameDto], + expenses: List[NewExpenseDto] ) final case class PutGroupRequest( @@ -20,20 +20,20 @@ final case class PutGroupRequest( ) final case class PostMemberRequest( - groupUid: String = "", - name: String = "" + groupUid: String, + name: String ) -final case class PutMemberRequest(name: String = "") +final case class PutMemberRequest(name: String) final case class PostExpenseRequest( - groupUid: String = "", - title: String = "", - description: String = "", - amount: Double = 0.0, - paidBy: List[UserUidDto] = Nil, - isSplitBetweenAll: Option[Boolean] = None, - splitBetween: List[UserUidDto] = Nil + groupUid: String, + title: String, + description: String, + amount: Double, + paidBy: List[UserUidDto], + isSplitBetweenAll: Option[Boolean], + splitBetween: List[UserUidDto] ) final case class PutExpenseRequest( 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 index da8a400..3a545c5 100644 --- 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 @@ -2,18 +2,18 @@ package com.github.ai.split.api.response import com.github.ai.split.api.{CurrencyDto, ExpenseDto, GetGroupErrorDto, GroupDto} -final case class GetCurrenciesResponse(currencies: List[CurrencyDto] = Nil) +final case class GetCurrenciesResponse(currencies: List[CurrencyDto]) final case class GetGroupsResponse( - groups: List[GroupDto] = Nil, - errors: List[GetGroupErrorDto] = Nil + groups: List[GroupDto], + errors: List[GetGroupErrorDto] ) -final case class PostGroupResponse(group: GroupDto = GroupDto()) -final case class PutGroupResponse(group: GroupDto = GroupDto()) -final case class PostMemberResponse(group: GroupDto = GroupDto()) -final case class PutMemberResponse(group: GroupDto = GroupDto()) -final case class DeleteMemberResponse(group: GroupDto = GroupDto()) -final case class PostExpenseResponse(expense: ExpenseDto = ExpenseDto()) -final case class PutExpenseResponse(expense: ExpenseDto = ExpenseDto()) -final case class DeleteExpenseResponse(group: GroupDto = GroupDto()) +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/openapi-schema/openapi.yaml b/backend/openapi-schema/openapi.yaml index 1d9bb5f..9f7927b 100644 --- a/backend/openapi-schema/openapi.yaml +++ b/backend/openapi-schema/openapi.yaml @@ -29,11 +29,17 @@ paths: required: true responses: '200': + description: |+ + Created expense + content: application/json: schema: $ref: '#/components/schemas/PostExpenseResponse' '400': + description: |+ + Bad request + content: application/json: schema: @@ -48,11 +54,17 @@ paths: - currencies responses: '200': + description: |+ + Supported currencies + content: application/json: schema: $ref: '#/components/schemas/GetCurrenciesResponse' '400': + description: |+ + Bad request + content: application/json: schema: @@ -89,11 +101,17 @@ paths: 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: @@ -121,11 +139,17 @@ paths: 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: @@ -156,11 +180,17 @@ paths: style: simple responses: '200': + description: |+ + Exported group data + content: text/csv: schema: type: string '400': + description: |+ + Bad request + content: application/json: schema: @@ -197,11 +227,17 @@ paths: required: true responses: '200': + description: |+ + Updated expense + content: application/json: schema: $ref: '#/components/schemas/PutExpenseResponse' '400': + description: |+ + Bad request + content: application/json: schema: @@ -229,11 +265,17 @@ paths: 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: @@ -264,11 +306,17 @@ paths: 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: @@ -298,11 +346,17 @@ paths: 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: @@ -320,11 +374,17 @@ paths: required: true responses: '200': + description: |+ + Created group + content: application/json: schema: $ref: '#/components/schemas/PostGroupResponse' '400': + description: |+ + Bad request + content: application/json: schema: @@ -361,11 +421,17 @@ paths: required: true responses: '200': + description: |+ + Updated group + content: application/json: schema: $ref: '#/components/schemas/PutGroupResponse' '400': + description: |+ + Bad request + content: application/json: schema: @@ -377,67 +443,48 @@ components: properties: description: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' paybackTransactions: type: array items: $ref: '#/components/schemas/TransactionDto' - description: If not set, this field defaults to the value of the default annotation. - default: [ - ] modified: $ref: '#/components/schemas/TimestampDto' - description: If not set, this field defaults to the value of the default annotation. - default: - timestampSeconds: 0 - formatted: '' members: type: array items: $ref: '#/components/schemas/MemberDto' - description: If not set, this field defaults to the value of the default annotation. - default: [ - ] title: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' expenses: type: array items: $ref: '#/components/schemas/ExpenseDto' - description: If not set, this field defaults to the value of the default annotation. - default: [ - ] created: $ref: '#/components/schemas/TimestampDto' - description: If not set, this field defaults to the value of the default annotation. - default: - timestampSeconds: 0 - formatted: '' currency: $ref: '#/components/schemas/CurrencyDto' - description: If not set, this field defaults to the value of the default annotation. - default: - isoCode: '' - name: '' - symbol: '' uid: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' + required: + - uid + - title + - description + - currency + - members + - expenses + - paybackTransactions + - created + - modified PostMemberRequest: type: object properties: groupUid: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' name: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' + required: + - groupUid + - name GetCurrenciesResponse: type: object properties: @@ -445,36 +492,22 @@ components: type: array items: $ref: '#/components/schemas/CurrencyDto' - description: If not set, this field defaults to the value of the default annotation. - default: [ - ] + required: + - currencies UserUidDto: type: object properties: uid: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' + required: + - uid PostGroupResponse: type: object properties: group: $ref: '#/components/schemas/GroupDto' - description: If not set, this field defaults to the value of the default annotation. - default: - uid: '' - title: '' - description: '' - currency: - isoCode: '' - name: '' - symbol: '' - created: - timestampSeconds: 0 - formatted: '' - modified: - timestampSeconds: 0 - formatted: '' + required: + - group PutExpenseRequest: type: object properties: @@ -522,42 +555,15 @@ components: properties: expense: $ref: '#/components/schemas/ExpenseDto' - description: If not set, this field defaults to the value of the default annotation. - default: - uid: '' - title: '' - description: '' - amount: 0.0 - currency: - isoCode: '' - name: '' - symbol: '' - created: - timestampSeconds: 0 - formatted: '' - modified: - timestampSeconds: 0 - formatted: '' + required: + - expense PutMemberResponse: type: object properties: group: $ref: '#/components/schemas/GroupDto' - description: If not set, this field defaults to the value of the default annotation. - default: - uid: '' - title: '' - description: '' - currency: - isoCode: '' - name: '' - symbol: '' - created: - timestampSeconds: 0 - formatted: '' - modified: - timestampSeconds: 0 - formatted: '' + required: + - group PostExpenseRequest: type: object properties: @@ -565,39 +571,30 @@ components: 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 - description: If not set, this field defaults to the value of the default annotation. - default: '' 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 - description: If not set, this field defaults to the value of the default annotation. - default: '' groupUid: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' amount: type: number format: double - description: If not set, this field defaults to the value of the default annotation. - default: 0.0 + required: + - groupUid + - title + - description + - amount + - paidBy + - splitBetween NewExpenseDto: type: object properties: @@ -605,42 +602,34 @@ components: type: array items: $ref: '#/components/schemas/UserNameDto' - description: If not set, this field defaults to the value of the default annotation. - default: [ - ] description: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' amount: type: number format: double - description: If not set, this field defaults to the value of the default annotation. - default: 0.0 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/UserNameDto' - description: If not set, this field defaults to the value of the default annotation. - default: [ - ] title: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' + required: + - title + - description + - amount + - paidBy + - splitBetween UserNameDto: type: object properties: name: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' + required: + - name GetGroupsResponse: type: object properties: @@ -648,51 +637,33 @@ components: type: array items: $ref: '#/components/schemas/GroupDto' - description: If not set, this field defaults to the value of the default annotation. - default: [ - ] errors: type: array items: $ref: '#/components/schemas/GetGroupErrorDto' - description: If not set, this field defaults to the value of the default annotation. - default: [ - ] + required: + - groups + - errors DeleteExpenseResponse: type: object properties: group: $ref: '#/components/schemas/GroupDto' - description: If not set, this field defaults to the value of the default annotation. - default: - uid: '' - title: '' - description: '' - currency: - isoCode: '' - name: '' - symbol: '' - created: - timestampSeconds: 0 - formatted: '' - modified: - timestampSeconds: 0 - formatted: '' + required: + - group CurrencyDto: type: object properties: isoCode: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' name: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' symbol: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' + required: + - isoCode + - name + - symbol ExpenseDto: type: object properties: @@ -700,52 +671,35 @@ components: type: array items: $ref: '#/components/schemas/MemberDto' - description: If not set, this field defaults to the value of the default annotation. - default: [ - ] description: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' amount: type: number format: double - description: If not set, this field defaults to the value of the default annotation. - default: 0.0 modified: $ref: '#/components/schemas/TimestampDto' - description: If not set, this field defaults to the value of the default annotation. - default: - timestampSeconds: 0 - formatted: '' paidBy: type: array items: $ref: '#/components/schemas/MemberDto' - description: If not set, this field defaults to the value of the default annotation. - default: [ - ] title: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' created: $ref: '#/components/schemas/TimestampDto' - description: If not set, this field defaults to the value of the default annotation. - default: - timestampSeconds: 0 - formatted: '' currency: $ref: '#/components/schemas/CurrencyDto' - description: If not set, this field defaults to the value of the default annotation. - default: - isoCode: '' - name: '' - symbol: '' uid: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' + required: + - uid + - title + - description + - amount + - currency + - paidBy + - splitBetween + - created + - modified PutGroupRequest: type: object properties: @@ -785,145 +739,93 @@ components: properties: group: $ref: '#/components/schemas/GroupDto' - description: If not set, this field defaults to the value of the default annotation. - default: - uid: '' - title: '' - description: '' - currency: - isoCode: '' - name: '' - symbol: '' - created: - timestampSeconds: 0 - formatted: '' - modified: - timestampSeconds: 0 - formatted: '' + required: + - group PutExpenseResponse: type: object properties: expense: $ref: '#/components/schemas/ExpenseDto' - description: If not set, this field defaults to the value of the default annotation. - default: - uid: '' - title: '' - description: '' - amount: 0.0 - currency: - isoCode: '' - name: '' - symbol: '' - created: - timestampSeconds: 0 - formatted: '' - modified: - timestampSeconds: 0 - formatted: '' + required: + - expense TransactionDto: type: object properties: creditorUid: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' debtorUid: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' amount: type: number format: double - description: If not set, this field defaults to the value of the default annotation. - default: 0.0 + required: + - creditorUid + - debtorUid + - amount PostGroupRequest: type: object properties: currencyIsoCode: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' description: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' members: type: array items: $ref: '#/components/schemas/UserNameDto' - description: If not set, this field defaults to the value of the default annotation. - default: [ - ] title: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' expenses: type: array items: $ref: '#/components/schemas/NewExpenseDto' - description: If not set, this field defaults to the value of the default annotation. - default: [ - ] password: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' + required: + - password + - title + - description + - currencyIsoCode + - members + - expenses PutGroupResponse: type: object properties: group: $ref: '#/components/schemas/GroupDto' - description: If not set, this field defaults to the value of the default annotation. - default: - uid: '' - title: '' - description: '' - currency: - isoCode: '' - name: '' - symbol: '' - created: - timestampSeconds: 0 - formatted: '' - modified: - timestampSeconds: 0 - formatted: '' + required: + - group MemberDto: type: object properties: uid: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' name: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' + required: + - uid + - name TimestampDto: type: object properties: timestampSeconds: type: integer format: int64 - description: If not set, this field defaults to the value of the default annotation. - default: 0 formatted: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' + required: + - timestampSeconds + - formatted GetGroupErrorDto: type: object properties: uid: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' message: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' + required: + - uid + - message ErrorMessageDto: type: object properties: @@ -931,47 +833,29 @@ components: type: - string - 'null' - description: If not set, this field defaults to the value of the default annotation. - default: null exception: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' stacktraceBase64: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' stacktraceLines: type: array items: type: string - description: If not set, this field defaults to the value of the default annotation. - default: [ - ] + required: + - exception + - stacktraceBase64 + - stacktraceLines DeleteMemberResponse: type: object properties: group: $ref: '#/components/schemas/GroupDto' - description: If not set, this field defaults to the value of the default annotation. - default: - uid: '' - title: '' - description: '' - currency: - isoCode: '' - name: '' - symbol: '' - created: - timestampSeconds: 0 - formatted: '' - modified: - timestampSeconds: 0 - formatted: '' + required: + - group PutMemberRequest: type: object properties: name: type: string - description: If not set, this field defaults to the value of the default annotation. - default: '' + 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 index c4085ee..9ebf6f2 100644 --- 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 @@ -5,7 +5,7 @@ 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.{HttpContentCodec, QueryCodec} +import zio.http.codec.{Doc, HttpContentCodec, QueryCodec} import zio.http.endpoint.Endpoint import zio.http.endpoint.openapi.{OpenAPI, OpenAPIGen} import zio.schema.Schema @@ -23,77 +23,77 @@ object ApiEndpoints { ) private def endpoint[PathInput](route: RoutePattern[PathInput]) = - Endpoint(route).outError[ErrorMessageDto](Status.BadRequest) + 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] + .out[GetGroupsResponse](Doc.p("Groups matching the supplied credentials")) .tag("groups") val postGroup = endpoint(Method.POST / "group") .in[PostGroupRequest] - .out[PostGroupResponse] + .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] + .out[PutGroupResponse](Doc.p("Updated group")) .tag("groups") val postMember = endpoint(Method.POST / "member") .query(QueryCodec.query[String]("password").optional) .in[PostMemberRequest] - .out[PostMemberResponse] + .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] + .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] + .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] + .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] + .out[PutExpenseResponse](Doc.p("Updated expense")) .tag("expenses") val deleteExpense = endpoint(Method.DELETE / "expense" / string("expenseId")) .query(QueryCodec.query[String]("password").optional) - .out[DeleteExpenseResponse] + .out[DeleteExpenseResponse](Doc.p("Group after removing the expense")) .tag("expenses") val getCurrencies = endpoint(Method.GET / "currency") - .out[GetCurrenciesResponse] + .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) + .out[String](MediaType.text.csv, Doc.p("Exported group data")) .tag("groups") val all = List(