Add routes for storing/retrieving client settings

This commit is contained in:
Eike Kettner 2021-05-25 00:06:13 +02:00
parent 8c127cde5f
commit 25788a0b23
10 changed files with 342 additions and 20 deletions

View File

@ -38,6 +38,7 @@ trait BackendApp[F[_]] {
def folder: OFolder[F] def folder: OFolder[F]
def customFields: OCustomFields[F] def customFields: OCustomFields[F]
def simpleSearch: OSimpleSearch[F] def simpleSearch: OSimpleSearch[F]
def clientSettings: OClientSettings[F]
} }
object BackendApp { object BackendApp {
@ -73,26 +74,28 @@ object BackendApp {
folderImpl <- OFolder(store) folderImpl <- OFolder(store)
customFieldsImpl <- OCustomFields(store) customFieldsImpl <- OCustomFields(store)
simpleSearchImpl = OSimpleSearch(fulltextImpl, itemSearchImpl) simpleSearchImpl = OSimpleSearch(fulltextImpl, itemSearchImpl)
clientSettingsImpl <- OClientSettings(store)
} yield new BackendApp[F] { } yield new BackendApp[F] {
val login = loginImpl val login = loginImpl
val signup = signupImpl val signup = signupImpl
val collective = collImpl val collective = collImpl
val source = sourceImpl val source = sourceImpl
val tag = tagImpl val tag = tagImpl
val equipment = equipImpl val equipment = equipImpl
val organization = orgImpl val organization = orgImpl
val upload = uploadImpl val upload = uploadImpl
val node = nodeImpl val node = nodeImpl
val job = jobImpl val job = jobImpl
val item = itemImpl val item = itemImpl
val itemSearch = itemSearchImpl val itemSearch = itemSearchImpl
val fulltext = fulltextImpl val fulltext = fulltextImpl
val mail = mailImpl val mail = mailImpl
val joex = joexImpl val joex = joexImpl
val userTask = userTaskImpl val userTask = userTaskImpl
val folder = folderImpl val folder = folderImpl
val customFields = customFieldsImpl val customFields = customFieldsImpl
val simpleSearch = simpleSearchImpl val simpleSearch = simpleSearchImpl
val clientSettings = clientSettingsImpl
} }
def apply[F[_]: ConcurrentEffect: ContextShift]( def apply[F[_]: ConcurrentEffect: ContextShift](

View File

@ -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
})
}

View File

@ -1185,6 +1185,68 @@ paths:
schema: schema:
$ref: "#/components/schemas/BasicResult" $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: /admin/user/resetPassword:
post: post:
tags: [ Collective, Admin ] tags: [ Collective, Admin ]
@ -5571,3 +5633,11 @@ components:
required: false required: false
schema: schema:
type: boolean type: boolean
clientId:
name: clientId
in: path
required: true
description: |
some identifier for a client application
schema:
type: string

View File

@ -91,7 +91,8 @@ object RestServer {
"calevent/check" -> CalEventCheckRoutes(), "calevent/check" -> CalEventCheckRoutes(),
"fts" -> FullTextIndexRoutes.secured(cfg, restApp.backend, token), "fts" -> FullTextIndexRoutes.secured(cfg, restApp.backend, token),
"folder" -> FolderRoutes(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] = def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] =

View File

@ -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
}
}
}

View File

@ -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")
);

View File

@ -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`)
);

View File

@ -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")
);

View File

@ -11,6 +11,7 @@ import doobie._
import doobie.implicits.legacy.instant._ import doobie.implicits.legacy.instant._
import doobie.util.log.Success import doobie.util.log.Success
import emil.doobie.EmilDoobieMeta import emil.doobie.EmilDoobieMeta
import io.circe.Json
import io.circe.{Decoder, Encoder} import io.circe.{Decoder, Encoder}
trait DoobieMeta extends EmilDoobieMeta { trait DoobieMeta extends EmilDoobieMeta {
@ -112,10 +113,18 @@ trait DoobieMeta extends EmilDoobieMeta {
implicit val metaOrgUse: Meta[OrgUse] = implicit val metaOrgUse: Meta[OrgUse] =
Meta[String].timap(OrgUse.unsafeFromString)(_.name) Meta[String].timap(OrgUse.unsafeFromString)(_.name)
implicit val metaJsonString: Meta[Json] =
Meta[String].timap(DoobieMeta.parseJsonUnsafe)(_.noSpaces)
} }
object DoobieMeta extends DoobieMeta { object DoobieMeta extends DoobieMeta {
import org.log4s._ import org.log4s._
private val logger = getLogger private val logger = getLogger
private def parseJsonUnsafe(str: String): Json =
io.circe.parser
.parse(str)
.fold(throw _, identity)
} }

View File

@ -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
}