From 25788a0b23417ee981c52477228e20a09dcd8fb4 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Tue, 25 May 2021 00:06:13 +0200 Subject: [PATCH] Add routes for storing/retrieving client settings --- .../scala/docspell/backend/BackendApp.scala | 41 +++++----- .../backend/ops/OClientSettings.scala | 78 ++++++++++++++++++ .../src/main/resources/docspell-openapi.yml | 70 ++++++++++++++++ .../docspell/restserver/RestServer.scala | 3 +- .../routes/ClientSettingsRoutes.scala | 52 ++++++++++++ .../migration/h2/V1.23.0__clientsettings.sql | 10 +++ .../mariadb/V1.23.0__clientsettings.sql | 10 +++ .../postgresql/V1.23.0__clientsettings.sql | 10 +++ .../docspell/store/impl/DoobieMeta.scala | 9 +++ .../store/records/RClientSettings.scala | 79 +++++++++++++++++++ 10 files changed, 342 insertions(+), 20 deletions(-) create mode 100644 modules/backend/src/main/scala/docspell/backend/ops/OClientSettings.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/routes/ClientSettingsRoutes.scala create mode 100644 modules/store/src/main/resources/db/migration/h2/V1.23.0__clientsettings.sql create mode 100644 modules/store/src/main/resources/db/migration/mariadb/V1.23.0__clientsettings.sql create mode 100644 modules/store/src/main/resources/db/migration/postgresql/V1.23.0__clientsettings.sql create mode 100644 modules/store/src/main/scala/docspell/store/records/RClientSettings.scala diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index 80df397c..534eb1ca 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -38,6 +38,7 @@ trait BackendApp[F[_]] { def folder: OFolder[F] def customFields: OCustomFields[F] def simpleSearch: OSimpleSearch[F] + def clientSettings: OClientSettings[F] } object BackendApp { @@ -73,26 +74,28 @@ object BackendApp { folderImpl <- OFolder(store) customFieldsImpl <- OCustomFields(store) simpleSearchImpl = OSimpleSearch(fulltextImpl, itemSearchImpl) + clientSettingsImpl <- OClientSettings(store) } yield new BackendApp[F] { - val login = loginImpl - val signup = signupImpl - val collective = collImpl - val source = sourceImpl - val tag = tagImpl - val equipment = equipImpl - val organization = orgImpl - val upload = uploadImpl - val node = nodeImpl - val job = jobImpl - val item = itemImpl - val itemSearch = itemSearchImpl - val fulltext = fulltextImpl - val mail = mailImpl - val joex = joexImpl - val userTask = userTaskImpl - val folder = folderImpl - val customFields = customFieldsImpl - val simpleSearch = simpleSearchImpl + val login = loginImpl + val signup = signupImpl + val collective = collImpl + val source = sourceImpl + val tag = tagImpl + val equipment = equipImpl + val organization = orgImpl + val upload = uploadImpl + val node = nodeImpl + val job = jobImpl + val item = itemImpl + val itemSearch = itemSearchImpl + val fulltext = fulltextImpl + val mail = mailImpl + val joex = joexImpl + val userTask = userTaskImpl + val folder = folderImpl + val customFields = customFieldsImpl + val simpleSearch = simpleSearchImpl + val clientSettings = clientSettingsImpl } def apply[F[_]: ConcurrentEffect: ContextShift]( diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OClientSettings.scala b/modules/backend/src/main/scala/docspell/backend/ops/OClientSettings.scala new file mode 100644 index 00000000..81f2aeec --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/ops/OClientSettings.scala @@ -0,0 +1,78 @@ +package docspell.backend.ops + +import cats.data.OptionT +import cats.effect.{Effect, Resource} +import cats.implicits._ + +import docspell.common.AccountId +import docspell.common._ +import docspell.common.syntax.all._ +import docspell.store.Store +import docspell.store.records.RClientSettings +import docspell.store.records.RUser + +import io.circe.Json +import org.log4s._ + +trait OClientSettings[F[_]] { + + def delete(clientId: Ident, account: AccountId): F[Boolean] + def save(clientId: Ident, account: AccountId, data: Json): F[Unit] + def load(clientId: Ident, account: AccountId): F[Option[RClientSettings]] + +} + +object OClientSettings { + private[this] val logger = getLogger + + def apply[F[_]: Effect](store: Store[F]): Resource[F, OClientSettings[F]] = + Resource.pure[F, OClientSettings[F]](new OClientSettings[F] { + + private def getUserId(account: AccountId): OptionT[F, Ident] = + OptionT(store.transact(RUser.findByAccount(account))).map(_.uid) + + def delete(clientId: Ident, account: AccountId): F[Boolean] = + (for { + _ <- OptionT.liftF( + logger.fdebug( + s"Deleting client settings for client ${clientId.id} and account $account" + ) + ) + userId <- getUserId(account) + n <- OptionT.liftF( + store.transact( + RClientSettings.delete(clientId, userId) + ) + ) + } yield n > 0).getOrElse(false) + + def save(clientId: Ident, account: AccountId, data: Json): F[Unit] = + (for { + _ <- OptionT.liftF( + logger.fdebug( + s"Storing client settings for client ${clientId.id} and account $account" + ) + ) + userId <- getUserId(account) + n <- OptionT.liftF( + store.transact(RClientSettings.upsert(clientId, userId, data)) + ) + _ <- OptionT.liftF( + if (n <= 0) Effect[F].raiseError(new Exception("No rows updated!")) + else ().pure[F] + ) + } yield ()).getOrElse(()) + + def load(clientId: Ident, account: AccountId): F[Option[RClientSettings]] = + (for { + _ <- OptionT.liftF( + logger.fdebug( + s"Loading client settings for client ${clientId.id} and account $account" + ) + ) + userId <- getUserId(account) + data <- OptionT(store.transact(RClientSettings.find(clientId, userId))) + } yield data).value + + }) +} diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 01f4bade..92766627 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1185,6 +1185,68 @@ paths: schema: $ref: "#/components/schemas/BasicResult" + /sec/clientSettings/{clientId}: + parameters: + - $ref: "#/components/parameters/clientId" + get: + tags: [ Client Settings ] + summary: Return the current user settings + description: | + Returns the settings for the current user. The `clientId` is + an identifier to a client application. It returns a JSON + structure. The server doesn't care about the actual data, + since it is meant to be interpreted by clients. + security: + - authTokenHeader: [] + responses: + 200: + description: Ok + content: + application/json: + schema: {} + put: + tags: [ Client Settings ] + summary: Update current user settings + description: | + Updates (replaces or creates) the current user's settings with + the given data. The `clientId` is an identifier to a client + application. The request body is expected to be JSON, the + structure is not important to the server. + + The data is stored for the current user and given `clientId`. + + The data is only saved without being checked in any way + (besides being valid JSON). It is returned "as is" to the + client in the corresponding GET endpoint. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: {} + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + delete: + tags: [ Client Settings ] + summary: Clears the current user settings + description: | + Removes all stored user settings for the client identified by + `clientId`. + security: + - authTokenHeader: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /admin/user/resetPassword: post: tags: [ Collective, Admin ] @@ -5571,3 +5633,11 @@ components: required: false schema: type: boolean + clientId: + name: clientId + in: path + required: true + description: | + some identifier for a client application + schema: + type: string diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 30e5733e..7891cb56 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -91,7 +91,8 @@ object RestServer { "calevent/check" -> CalEventCheckRoutes(), "fts" -> FullTextIndexRoutes.secured(cfg, restApp.backend, token), "folder" -> FolderRoutes(restApp.backend, token), - "customfield" -> CustomFieldRoutes(restApp.backend, token) + "customfield" -> CustomFieldRoutes(restApp.backend, token), + "clientSettings" -> ClientSettingsRoutes(restApp.backend, token) ) def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] = diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ClientSettingsRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ClientSettingsRoutes.scala new file mode 100644 index 00000000..3672a35b --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ClientSettingsRoutes.scala @@ -0,0 +1,52 @@ +package docspell.restserver.routes + +import cats.effect._ +import cats.implicits._ + +import docspell.backend.BackendApp +import docspell.backend.auth.AuthToken +import docspell.common._ +import docspell.restapi.model._ + +import io.circe.Json +import org.http4s.HttpRoutes +import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl + +object ClientSettingsRoutes { + + def apply[F[_]: Effect](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {} + import dsl._ + + HttpRoutes.of { + case req @ PUT -> Root / Ident(clientId) => + for { + data <- req.as[Json] + _ <- backend.clientSettings.save(clientId, user.account, data) + res <- Ok(BasicResult(true, "Settings stored")) + } yield res + + case GET -> Root / Ident(clientId) => + for { + data <- backend.clientSettings.load(clientId, user.account) + res <- data match { + case Some(d) => Ok(d.settingsData) + case None => NotFound() + } + } yield res + + case DELETE -> Root / Ident(clientId) => + for { + flag <- backend.clientSettings.delete(clientId, user.account) + res <- Ok( + BasicResult( + flag, + if (flag) "Settings deleted" else "Deleting settings failed" + ) + ) + } yield res + } + } +} diff --git a/modules/store/src/main/resources/db/migration/h2/V1.23.0__clientsettings.sql b/modules/store/src/main/resources/db/migration/h2/V1.23.0__clientsettings.sql new file mode 100644 index 00000000..b06c44b4 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.23.0__clientsettings.sql @@ -0,0 +1,10 @@ +CREATE TABLE "client_settings" ( + "id" varchar(254) not null primary key, + "client_id" varchar(254) not null, + "user_id" varchar(254) not null, + "settings_data" text not null, + "created" timestamp not null, + "updated" timestamp not null, + foreign key ("user_id") references "user_"("uid") on delete cascade, + unique ("client_id", "user_id") +); diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.23.0__clientsettings.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.23.0__clientsettings.sql new file mode 100644 index 00000000..7014ccd9 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.23.0__clientsettings.sql @@ -0,0 +1,10 @@ +CREATE TABLE `client_settings` ( + `id` varchar(254) not null primary key, + `client_id` varchar(254) not null, + `user_id` varchar(254) not null, + `settings_data` longtext not null, + `created` timestamp not null, + `updated` timestamp not null, + foreign key (`user_id`) references `user_`(`uid`) on delete cascade, + unique (`client_id`, `user_id`) +); diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.23.0__clientsettings.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.23.0__clientsettings.sql new file mode 100644 index 00000000..b06c44b4 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.23.0__clientsettings.sql @@ -0,0 +1,10 @@ +CREATE TABLE "client_settings" ( + "id" varchar(254) not null primary key, + "client_id" varchar(254) not null, + "user_id" varchar(254) not null, + "settings_data" text not null, + "created" timestamp not null, + "updated" timestamp not null, + foreign key ("user_id") references "user_"("uid") on delete cascade, + unique ("client_id", "user_id") +); diff --git a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala index e15da7ae..c38ff900 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala @@ -11,6 +11,7 @@ import doobie._ import doobie.implicits.legacy.instant._ import doobie.util.log.Success import emil.doobie.EmilDoobieMeta +import io.circe.Json import io.circe.{Decoder, Encoder} trait DoobieMeta extends EmilDoobieMeta { @@ -112,10 +113,18 @@ trait DoobieMeta extends EmilDoobieMeta { implicit val metaOrgUse: Meta[OrgUse] = Meta[String].timap(OrgUse.unsafeFromString)(_.name) + + implicit val metaJsonString: Meta[Json] = + Meta[String].timap(DoobieMeta.parseJsonUnsafe)(_.noSpaces) } object DoobieMeta extends DoobieMeta { import org.log4s._ private val logger = getLogger + private def parseJsonUnsafe(str: String): Json = + io.circe.parser + .parse(str) + .fold(throw _, identity) + } diff --git a/modules/store/src/main/scala/docspell/store/records/RClientSettings.scala b/modules/store/src/main/scala/docspell/store/records/RClientSettings.scala new file mode 100644 index 00000000..ed7c3911 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RClientSettings.scala @@ -0,0 +1,79 @@ +package docspell.store.records + +import cats.data.NonEmptyList +import cats.implicits._ + +import docspell.common._ +import docspell.store.qb.DSL._ +import docspell.store.qb._ + +import doobie._ +import doobie.implicits._ +import io.circe.Json + +case class RClientSettings( + id: Ident, + clientId: Ident, + userId: Ident, + settingsData: Json, + updated: Timestamp, + created: Timestamp +) {} + +object RClientSettings { + + final case class Table(alias: Option[String]) extends TableDef { + val tableName = "client_settings" + + val id = Column[Ident]("id", this) + val clientId = Column[Ident]("client_id", this) + val userId = Column[Ident]("user_id", this) + val settingsData = Column[Json]("settings_data", this) + val updated = Column[Timestamp]("updated", this) + val created = Column[Timestamp]("created", this) + val all = + NonEmptyList.of[Column[_]](id, clientId, userId, settingsData, updated, created) + } + + def as(alias: String): Table = Table(Some(alias)) + val T = Table(None) + + def insert(v: RClientSettings): ConnectionIO[Int] = { + val t = Table(None) + DML.insert( + t, + t.all, + fr"${v.id},${v.clientId},${v.userId},${v.settingsData},${v.updated},${v.created}" + ) + } + + def updateSettings( + clientId: Ident, + userId: Ident, + data: Json, + updateTs: Timestamp + ): ConnectionIO[Int] = + DML.update( + T, + T.clientId === clientId && T.userId === userId, + DML.set(T.settingsData.setTo(data), T.updated.setTo(updateTs)) + ) + + def upsert(clientId: Ident, userId: Ident, data: Json): ConnectionIO[Int] = + for { + id <- Ident.randomId[ConnectionIO] + now <- Timestamp.current[ConnectionIO] + nup <- updateSettings(clientId, userId, data, now) + nin <- + if (nup <= 0) insert(RClientSettings(id, clientId, userId, data, now, now)) + else 0.pure[ConnectionIO] + } yield nup + nin + + def delete(clientId: Ident, userId: Ident): ConnectionIO[Int] = + DML.delete(T, T.clientId === clientId && T.userId === userId) + + def find(clientId: Ident, userId: Ident): ConnectionIO[Option[RClientSettings]] = + run(select(T.all), from(T), T.clientId === clientId && T.userId === userId) + .query[RClientSettings] + .option +}