Move all file in fr.dcproject.
This commit is contained in:
212
src/main/kotlin/fr/dcproject/application/Application.kt
Normal file
212
src/main/kotlin/fr/dcproject/application/Application.kt
Normal file
@@ -0,0 +1,212 @@
|
||||
package fr.dcproject.application
|
||||
|
||||
import com.fasterxml.jackson.core.util.DefaultIndenter
|
||||
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import com.fasterxml.jackson.datatype.joda.JodaModule
|
||||
import com.github.jasync.sql.db.postgresql.exceptions.GenericDatabaseException
|
||||
import fr.dcproject.application.Env.PROD
|
||||
import fr.dcproject.application.Env.TEST
|
||||
import fr.dcproject.common.security.AccessDeniedException
|
||||
import fr.dcproject.component.article.articleKoinModule
|
||||
import fr.dcproject.component.article.routes.installArticleRoutes
|
||||
import fr.dcproject.component.auth.ForbiddenException
|
||||
import fr.dcproject.component.auth.authKoinModule
|
||||
import fr.dcproject.component.auth.jwt.jwtInstallation
|
||||
import fr.dcproject.component.auth.routes.installAuthRoutes
|
||||
import fr.dcproject.component.auth.user
|
||||
import fr.dcproject.component.citizen.citizenKoinModule
|
||||
import fr.dcproject.component.citizen.routes.installCitizenRoutes
|
||||
import fr.dcproject.component.comment.article.routes.installCommentArticleRoutes
|
||||
import fr.dcproject.component.comment.commentKoinModule
|
||||
import fr.dcproject.component.comment.constitution.routes.installCommentConstitutionRoutes
|
||||
import fr.dcproject.component.comment.generic.routes.installCommentRoutes
|
||||
import fr.dcproject.component.constitution.constitutionKoinModule
|
||||
import fr.dcproject.component.constitution.routes.installConstitutionRoutes
|
||||
import fr.dcproject.component.doc.routes.installDocRoutes
|
||||
import fr.dcproject.component.follow.followKoinModule
|
||||
import fr.dcproject.component.follow.routes.article.installFollowArticleRoutes
|
||||
import fr.dcproject.component.follow.routes.constitution.installFollowConstitutionRoutes
|
||||
import fr.dcproject.component.notification.NotificationConsumer
|
||||
import fr.dcproject.component.notification.routes.installNotificationsRoutes
|
||||
import fr.dcproject.component.opinion.opinionKoinModule
|
||||
import fr.dcproject.component.opinion.routes.installOpinionRoutes
|
||||
import fr.dcproject.component.views.viewKoinModule
|
||||
import fr.dcproject.component.vote.routes.installVoteRoutes
|
||||
import fr.dcproject.component.vote.voteKoinModule
|
||||
import fr.dcproject.component.workgroup.routes.installWorkgroupRoutes
|
||||
import fr.dcproject.component.workgroup.workgroupKoinModule
|
||||
import fr.postgresjson.migration.Migrations
|
||||
import io.ktor.application.Application
|
||||
import io.ktor.application.ApplicationStopped
|
||||
import io.ktor.application.call
|
||||
import io.ktor.application.install
|
||||
import io.ktor.auth.Authentication
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.jetty.Jetty
|
||||
import io.ktor.features.AutoHeadResponse
|
||||
import io.ktor.features.CORS
|
||||
import io.ktor.features.CallLogging
|
||||
import io.ktor.features.ContentNegotiation
|
||||
import io.ktor.features.DataConversion
|
||||
import io.ktor.features.NotFoundException
|
||||
import io.ktor.features.StatusPages
|
||||
import io.ktor.features.maxAge
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.cio.websocket.pingPeriod
|
||||
import io.ktor.http.cio.websocket.timeout
|
||||
import io.ktor.jackson.jackson
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Locations
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Routing
|
||||
import io.ktor.server.jetty.EngineMain
|
||||
import io.ktor.util.KtorExperimentalAPI
|
||||
import io.ktor.websocket.WebSockets
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import org.eclipse.jetty.util.log.Slf4jLog
|
||||
import org.koin.dsl.module
|
||||
import org.koin.ktor.ext.Koin
|
||||
import org.koin.ktor.ext.get
|
||||
import org.slf4j.event.Level
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.CompletionException
|
||||
|
||||
fun main(args: Array<String>): Unit = EngineMain.main(args)
|
||||
|
||||
enum class Env { PROD, TEST, CUCUMBER }
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@KtorExperimentalAPI
|
||||
@KtorExperimentalLocationsAPI
|
||||
@Suppress("unused") // Referenced in application.conf
|
||||
fun Application.module(env: Env = PROD) {
|
||||
install(Koin) {
|
||||
Slf4jLog()
|
||||
modules(
|
||||
listOf(
|
||||
if (env == TEST) module { single { Configuration("application-test.conf") } }
|
||||
else module { single { Configuration() } },
|
||||
KoinModule,
|
||||
articleKoinModule,
|
||||
authKoinModule,
|
||||
citizenKoinModule,
|
||||
commentKoinModule,
|
||||
constitutionKoinModule,
|
||||
followKoinModule,
|
||||
opinionKoinModule,
|
||||
viewKoinModule,
|
||||
voteKoinModule,
|
||||
workgroupKoinModule,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
install(CallLogging) {
|
||||
level = Level.INFO
|
||||
}
|
||||
|
||||
install(DataConversion, converters)
|
||||
|
||||
install(Locations)
|
||||
|
||||
HttpClient(Jetty) {
|
||||
engine {
|
||||
}
|
||||
}
|
||||
|
||||
install(WebSockets) {
|
||||
pingPeriod = Duration.ofSeconds(60) // Disabled (null) by default
|
||||
timeout = Duration.ofSeconds(15)
|
||||
maxFrameSize = Long.MAX_VALUE // Disabled (max value). The connection will be closed if surpassed this length.
|
||||
masking = false
|
||||
}
|
||||
|
||||
get<NotificationConsumer>().run {
|
||||
start()
|
||||
environment.monitor.subscribe(ApplicationStopped) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
install(Authentication, jwtInstallation(get()))
|
||||
|
||||
install(AutoHeadResponse)
|
||||
|
||||
install(ContentNegotiation) {
|
||||
jackson {
|
||||
propertyNamingStrategy = PropertyNamingStrategies.SNAKE_CASE
|
||||
|
||||
registerModule(JodaModule())
|
||||
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
configure(SerializationFeature.INDENT_OUTPUT, true)
|
||||
setDefaultPrettyPrinter(
|
||||
DefaultPrettyPrinter().apply {
|
||||
indentArraysWith(DefaultPrettyPrinter.FixedSpaceIndenter.instance)
|
||||
indentObjectsWith(DefaultIndenter(" ", "\n"))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
install(Routing.Feature) {
|
||||
// trace { application.log.trace(it.buildText()) }
|
||||
installArticleRoutes()
|
||||
installAuthRoutes()
|
||||
installCitizenRoutes()
|
||||
installCommentArticleRoutes()
|
||||
installCommentRoutes()
|
||||
installFollowArticleRoutes()
|
||||
installFollowConstitutionRoutes()
|
||||
installWorkgroupRoutes()
|
||||
installOpinionRoutes()
|
||||
installVoteRoutes()
|
||||
installConstitutionRoutes()
|
||||
installCommentConstitutionRoutes()
|
||||
installNotificationsRoutes()
|
||||
installDocRoutes()
|
||||
}
|
||||
|
||||
install(StatusPages) {
|
||||
// TODO move to postgresJson lib
|
||||
exception<CompletionException> { e ->
|
||||
val parent = e.cause?.cause
|
||||
if (parent is GenericDatabaseException) {
|
||||
call.respond(HttpStatusCode.BadRequest, parent.errorMessage.message!!)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
exception<NotFoundException> { e ->
|
||||
call.respond(HttpStatusCode.NotFound, e.message!!)
|
||||
}
|
||||
exception<AccessDeniedException> {
|
||||
if (call.user == null) call.respond(HttpStatusCode.Unauthorized)
|
||||
else call.respond(HttpStatusCode.Forbidden)
|
||||
}
|
||||
exception<ForbiddenException> {
|
||||
call.respond(HttpStatusCode.Forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
install(CORS) {
|
||||
method(HttpMethod.Options)
|
||||
method(HttpMethod.Put)
|
||||
method(HttpMethod.Delete)
|
||||
header(HttpHeaders.Authorization)
|
||||
anyHost()
|
||||
// host("localhost:4200", schemes = listOf("http", "https"))
|
||||
allowCredentials = true
|
||||
allowSameOrigin = true
|
||||
maxAge = Duration.ofDays(1)
|
||||
}
|
||||
|
||||
if (env == PROD) {
|
||||
get<Migrations>().run()
|
||||
}
|
||||
}
|
||||
46
src/main/kotlin/fr/dcproject/application/Configuration.kt
Normal file
46
src/main/kotlin/fr/dcproject/application/Configuration.kt
Normal file
@@ -0,0 +1,46 @@
|
||||
package fr.dcproject.application
|
||||
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import java.net.URI
|
||||
|
||||
class Configuration(val config: Config) {
|
||||
constructor(resourceBasename: String? = null) : this(if (resourceBasename == null) ConfigFactory.load() else ConfigFactory.load(resourceBasename))
|
||||
|
||||
interface Sql {
|
||||
val migrationFiles: URI
|
||||
val functionFiles: URI
|
||||
val fixtureFiles: URI
|
||||
}
|
||||
val sql
|
||||
get() = object : Sql {
|
||||
override val migrationFiles: URI = this::class.java.getResource("/sql/migrations")?.toURI() ?: error("No migrations found")
|
||||
override val functionFiles: URI = this::class.java.getResource("/sql/functions")?.toURI() ?: error("No sql function found")
|
||||
override val fixtureFiles: URI = this::class.java.getResource("/sql/fixtures")?.toURI() ?: error("No sql fixture found")
|
||||
}
|
||||
|
||||
interface Database {
|
||||
val host: String
|
||||
val port: Int
|
||||
var database: String
|
||||
var username: String
|
||||
var password: String
|
||||
}
|
||||
val database
|
||||
get() = object : Database {
|
||||
override val host: String = config.getString("db.host")
|
||||
override val port: Int = config.getInt("db.port")
|
||||
override var database: String = config.getString("db.database")
|
||||
override var username: String = config.getString("db.username")
|
||||
override var password: String = config.getString("db.password")
|
||||
}
|
||||
|
||||
val envName: String = config.getString("app.envName")
|
||||
val domain: String = config.getString("app.domain")
|
||||
|
||||
val redis: String = config.getString("redis.connection")
|
||||
val elasticsearch: String = config.getString("elasticsearch.connection")
|
||||
val rabbitmq: String = config.getString("rabbitmq.connection")
|
||||
val exchangeNotificationName = "notification"
|
||||
val sendGridKey: String = config.getString("mail.sendGrid.key")
|
||||
}
|
||||
31
src/main/kotlin/fr/dcproject/application/Converters.kt
Normal file
31
src/main/kotlin/fr/dcproject/application/Converters.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package fr.dcproject.application
|
||||
|
||||
import io.ktor.features.DataConversion
|
||||
import io.ktor.util.KtorExperimentalAPI
|
||||
import org.koin.core.context.GlobalContext
|
||||
import org.koin.core.parameter.ParametersDefinition
|
||||
import org.koin.core.qualifier.Qualifier
|
||||
import java.util.UUID
|
||||
|
||||
private typealias ConverterDeclaration = DataConversion.Configuration.() -> Unit
|
||||
private inline fun <reified T> DataConversion.Configuration.get(
|
||||
qualifier: Qualifier? = null,
|
||||
noinline parameters: ParametersDefinition? = null
|
||||
): T = GlobalContext.get().koin.rootScope.get(qualifier, parameters)
|
||||
|
||||
@KtorExperimentalAPI
|
||||
val converters: ConverterDeclaration = {
|
||||
convert<UUID> {
|
||||
decode { values, _ ->
|
||||
values.singleOrNull()?.let { UUID.fromString(it) }
|
||||
}
|
||||
|
||||
encode { value ->
|
||||
when (value) {
|
||||
null -> listOf()
|
||||
is UUID -> listOf(value.toString())
|
||||
else -> throw InternalError("Cannot convert $value as UUID")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
110
src/main/kotlin/fr/dcproject/application/KoinModule.kt
Normal file
110
src/main/kotlin/fr/dcproject/application/KoinModule.kt
Normal file
@@ -0,0 +1,110 @@
|
||||
package fr.dcproject.application
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategy
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import com.fasterxml.jackson.datatype.joda.JodaModule
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.rabbitmq.client.ConnectionFactory
|
||||
import fr.dcproject.common.email.Mailer
|
||||
import fr.dcproject.component.notification.NotificationConsumer
|
||||
import fr.dcproject.component.notification.NotificationEmailSender
|
||||
import fr.dcproject.component.notification.NotificationsPush
|
||||
import fr.dcproject.component.notification.Publisher
|
||||
import fr.postgresjson.connexion.Connection
|
||||
import fr.postgresjson.connexion.Requester
|
||||
import fr.postgresjson.migration.Migrations
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.features.websocket.WebSockets
|
||||
import io.ktor.util.KtorExperimentalAPI
|
||||
import io.lettuce.core.RedisClient
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
@KtorExperimentalAPI
|
||||
val KoinModule = module {
|
||||
// SQL connection
|
||||
single {
|
||||
val config: Configuration = get()
|
||||
Connection(
|
||||
host = config.database.host,
|
||||
port = config.database.port,
|
||||
database = config.database.database,
|
||||
username = config.database.username,
|
||||
password = config.database.password
|
||||
)
|
||||
}
|
||||
|
||||
// Launch Database migration
|
||||
single {
|
||||
val config: Configuration = get()
|
||||
Migrations(get(), config.sql.migrationFiles, config.sql.functionFiles)
|
||||
}
|
||||
|
||||
// Redis client
|
||||
single<RedisClient> {
|
||||
val config: Configuration = get()
|
||||
RedisClient.create(config.redis).apply {
|
||||
connect().sync().configSet("notify-keyspace-events", "KEA")
|
||||
}
|
||||
}
|
||||
|
||||
single { NotificationsPush.Builder(get()) }
|
||||
|
||||
single {
|
||||
val config: Configuration = get()
|
||||
NotificationConsumer(get(), get(), get(), get(), get(), config.exchangeNotificationName)
|
||||
}
|
||||
|
||||
// RabbitMQ
|
||||
single<ConnectionFactory> {
|
||||
val config: Configuration = get()
|
||||
ConnectionFactory().apply { setUri(config.rabbitmq) }
|
||||
}
|
||||
|
||||
// JsonSerializer
|
||||
single<ObjectMapper> {
|
||||
jacksonObjectMapper().apply {
|
||||
registerModule(SimpleModule())
|
||||
propertyNamingStrategy = PropertyNamingStrategy.SNAKE_CASE
|
||||
|
||||
registerModule(JodaModule())
|
||||
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Client HTTP for WebSockets
|
||||
single(named("ws")) {
|
||||
HttpClient {
|
||||
install(WebSockets)
|
||||
}
|
||||
}
|
||||
|
||||
// SQL Requester (postgresJson)
|
||||
single {
|
||||
val config: Configuration = get()
|
||||
Requester.RequesterFactory(
|
||||
connection = get(),
|
||||
functionsDirectory = config.sql.functionFiles
|
||||
).createRequester()
|
||||
}
|
||||
|
||||
// Mailer
|
||||
single {
|
||||
val config: Configuration = get()
|
||||
Mailer(config.sendGridKey)
|
||||
}
|
||||
|
||||
single {
|
||||
val config: Configuration = get()
|
||||
Publisher(factory = get(), exchangeName = config.exchangeNotificationName)
|
||||
}
|
||||
|
||||
single {
|
||||
val config: Configuration = get()
|
||||
NotificationEmailSender(get<Mailer>(), config.domain, get(), get())
|
||||
}
|
||||
}
|
||||
12
src/main/kotlin/fr/dcproject/common/dto/CreatedAt.kt
Normal file
12
src/main/kotlin/fr/dcproject/common/dto/CreatedAt.kt
Normal file
@@ -0,0 +1,12 @@
|
||||
package fr.dcproject.common.dto
|
||||
|
||||
import fr.postgresjson.entity.EntityCreatedAt
|
||||
import org.joda.time.DateTime
|
||||
|
||||
interface CreatedAt {
|
||||
val createdAt: DateTime
|
||||
|
||||
class Imp(parent: EntityCreatedAt) : CreatedAt {
|
||||
override val createdAt: DateTime = parent.createdAt
|
||||
}
|
||||
}
|
||||
14
src/main/kotlin/fr/dcproject/common/dto/Versionable.kt
Normal file
14
src/main/kotlin/fr/dcproject/common/dto/Versionable.kt
Normal file
@@ -0,0 +1,14 @@
|
||||
package fr.dcproject.common.dto
|
||||
|
||||
import fr.postgresjson.entity.EntityVersioning
|
||||
import java.util.UUID
|
||||
|
||||
interface Versionable {
|
||||
val versionId: UUID
|
||||
val versionNumber: Int
|
||||
|
||||
class Imp(parent: EntityVersioning<UUID, Int>) : Versionable {
|
||||
override val versionNumber: Int = parent.versionNumber
|
||||
override val versionId: UUID = parent.versionId
|
||||
}
|
||||
}
|
||||
27
src/main/kotlin/fr/dcproject/common/email/Mailer.kt
Normal file
27
src/main/kotlin/fr/dcproject/common/email/Mailer.kt
Normal file
@@ -0,0 +1,27 @@
|
||||
package fr.dcproject.common.email
|
||||
|
||||
import com.sendgrid.Method
|
||||
import com.sendgrid.Request
|
||||
import com.sendgrid.SendGrid
|
||||
import com.sendgrid.helpers.mail.Mail
|
||||
import java.io.IOException
|
||||
|
||||
class Mailer(
|
||||
private val key: String
|
||||
) {
|
||||
fun sendEmail(action: () -> Mail): Boolean {
|
||||
val mail = action()
|
||||
|
||||
val sg = SendGrid(key)
|
||||
val request = Request()
|
||||
try {
|
||||
request.method = Method.POST
|
||||
request.endpoint = "mail/send"
|
||||
request.body = mail.build()
|
||||
val response = sg.api(request)
|
||||
return response.statusCode == 202
|
||||
} catch (ex: IOException) {
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/main/kotlin/fr/dcproject/common/entity/CreatedBy.kt
Normal file
14
src/main/kotlin/fr/dcproject/common/entity/CreatedBy.kt
Normal file
@@ -0,0 +1,14 @@
|
||||
package fr.dcproject.common.entity
|
||||
|
||||
import fr.dcproject.component.citizen.CitizenI
|
||||
import fr.postgresjson.entity.EntityCreatedBy
|
||||
import fr.postgresjson.entity.EntityI
|
||||
|
||||
/**
|
||||
* TODO remove EntityCreatedBy<EntityI>
|
||||
*/
|
||||
interface CreatedBy<T : CitizenI> : EntityCreatedBy<EntityI> {
|
||||
override val createdBy: T
|
||||
}
|
||||
|
||||
class CreatedByImp<T : CitizenI>(override val createdBy: T) : CreatedBy<T>
|
||||
8
src/main/kotlin/fr/dcproject/common/entity/EntityI.kt
Normal file
8
src/main/kotlin/fr/dcproject/common/entity/EntityI.kt
Normal file
@@ -0,0 +1,8 @@
|
||||
package fr.dcproject.common.entity
|
||||
|
||||
import fr.postgresjson.entity.EntityI
|
||||
import java.util.UUID
|
||||
|
||||
interface EntityI : EntityI {
|
||||
val id: UUID
|
||||
}
|
||||
66
src/main/kotlin/fr/dcproject/common/entity/Extra.kt
Normal file
66
src/main/kotlin/fr/dcproject/common/entity/Extra.kt
Normal file
@@ -0,0 +1,66 @@
|
||||
package fr.dcproject.common.entity
|
||||
|
||||
import fr.dcproject.component.article.ArticleRef
|
||||
import fr.dcproject.component.citizen.CitizenI
|
||||
import fr.dcproject.component.comment.generic.CommentRef
|
||||
import fr.dcproject.component.constitution.ConstitutionRef
|
||||
import fr.dcproject.component.opinion.entity.OpinionRef
|
||||
import fr.postgresjson.entity.EntityCreatedAt
|
||||
import fr.postgresjson.entity.EntityCreatedBy
|
||||
import fr.postgresjson.entity.UuidEntity
|
||||
import fr.postgresjson.entity.UuidEntityI
|
||||
import java.util.UUID
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.full.isSubclassOf
|
||||
|
||||
interface ExtraI<T : TargetI, C : CitizenI> :
|
||||
UuidEntityI,
|
||||
HasTarget<T>,
|
||||
EntityCreatedAt,
|
||||
EntityCreatedBy<C>
|
||||
|
||||
interface HasTarget<T : TargetI> {
|
||||
val target: T
|
||||
}
|
||||
|
||||
open class TargetRef(id: UUID? = null, reference: String = "") : TargetI, UuidEntity(id) {
|
||||
|
||||
final override val reference: String
|
||||
get() = if (field != "") field else TargetI.getReference(this)
|
||||
|
||||
init {
|
||||
this.reference = reference
|
||||
}
|
||||
}
|
||||
|
||||
interface TargetI : UuidEntityI {
|
||||
enum class TargetName(val targetReference: String) {
|
||||
Article("article"),
|
||||
Constitution("constitution"),
|
||||
Comment("comment"),
|
||||
Opinion("opinion")
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun <T : TargetI> getReference(t: KClass<T>): String {
|
||||
return when {
|
||||
t.isSubclassOf(ArticleRef::class) -> TargetName.Article.targetReference
|
||||
t.isSubclassOf(ConstitutionRef::class) -> TargetName.Constitution.targetReference
|
||||
t.isSubclassOf(CommentRef::class) -> TargetName.Comment.targetReference
|
||||
t.isSubclassOf(OpinionRef::class) -> TargetName.Opinion.targetReference
|
||||
else -> throw error("target not implemented: ${t.qualifiedName} \nImplement it or return 'reference' from SQL")
|
||||
}
|
||||
}
|
||||
|
||||
fun getReference(t: TargetI): String {
|
||||
val ref = this.getReference(t::class)
|
||||
return if (t is ExtraI<*, *>) {
|
||||
"${ref}_on_${t.target.reference}"
|
||||
} else {
|
||||
ref
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val reference: String
|
||||
}
|
||||
17
src/main/kotlin/fr/dcproject/common/entity/Versionable.kt
Normal file
17
src/main/kotlin/fr/dcproject/common/entity/Versionable.kt
Normal file
@@ -0,0 +1,17 @@
|
||||
package fr.dcproject.common.entity
|
||||
|
||||
import fr.postgresjson.entity.EntityVersioning
|
||||
import java.util.UUID
|
||||
|
||||
interface VersionableRef {
|
||||
val versionId: UUID
|
||||
}
|
||||
|
||||
class VersionableRefImp(
|
||||
override val versionId: UUID
|
||||
) : VersionableRef
|
||||
|
||||
interface Versionable : VersionableRef, EntityVersioning<UUID, Int> {
|
||||
override val versionId: UUID
|
||||
override val versionNumber: Int
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package fr.dcproject.routes
|
||||
|
||||
interface PaginatedRequestI {
|
||||
val page: Int
|
||||
val limit: Int
|
||||
}
|
||||
|
||||
open class PaginatedRequest(
|
||||
page: Int = 1,
|
||||
limit: Int = 50
|
||||
) : PaginatedRequestI {
|
||||
override val page: Int = if (page < 1) 1 else page
|
||||
override val limit: Int = if (limit > 50) 50 else if (limit < 1) 1 else limit
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package fr.dcproject.common.security
|
||||
|
||||
/** Responses of AccessControl */
|
||||
enum class AccessDecision {
|
||||
GRANTED,
|
||||
DENIED;
|
||||
|
||||
/**
|
||||
* Convert decision to boolean
|
||||
*/
|
||||
fun toBoolean(): Boolean = when (this) {
|
||||
GRANTED -> true
|
||||
DENIED -> false
|
||||
}
|
||||
}
|
||||
|
||||
abstract class AccessControl {
|
||||
/**
|
||||
* A Shortcut for return a GrantedResponse
|
||||
*/
|
||||
protected fun granted(message: String? = null, code: String? = null): GrantedResponse = GrantedResponse(this, message, code)
|
||||
/**
|
||||
* A Shortcut for return a DeniedResponse
|
||||
*/
|
||||
protected fun denied(message: String, code: String): DeniedResponse = DeniedResponse(this, message, code)
|
||||
|
||||
/**
|
||||
* Check all responses and return DENIED if one is DENIED
|
||||
*
|
||||
* If the list of responses is empty, return GRANTED
|
||||
*/
|
||||
private fun AccessResponses.getOneResponse(): AccessResponse = this.firstOrNull { it.decision == AccessDecision.DENIED } ?: granted()
|
||||
|
||||
/**
|
||||
* An helper to convert a list of subject into one response
|
||||
*/
|
||||
protected fun <S : List<T>, T> canAll(items: S, action: (T) -> AccessResponse): AccessResponse = items
|
||||
.map { action(it) }
|
||||
.getOneResponse()
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw an Exception if AccessControl return a DENIED response
|
||||
*/
|
||||
fun <T : AccessControl> T.assert(action: T.() -> AccessResponse) {
|
||||
action().assert()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all responses and return DENIED if one is DENIED
|
||||
*
|
||||
* If the list of responses is empty, return GRANTED
|
||||
*/
|
||||
fun AccessResponses.getOneResponse(): AccessResponse = this.firstOrNull { it.decision == AccessDecision.DENIED } ?: GrantedResponse(first().accessControl)
|
||||
|
||||
/**
|
||||
* Throw an Exception if one response is DENIED
|
||||
*/
|
||||
fun AccessResponses.assert() = this.getOneResponse().assert()
|
||||
|
||||
class AccessDeniedException(private val accessResponses: AccessResponses) : Throwable(accessResponses.first().message) {
|
||||
constructor(accessResponse: AccessResponse) : this(listOf(accessResponse))
|
||||
|
||||
/**
|
||||
* Get first response
|
||||
*/
|
||||
fun first(): AccessResponse = accessResponses.first()
|
||||
|
||||
/**
|
||||
* Check if the error code is present into the responses
|
||||
*/
|
||||
fun hasErrorCode(code: String): Boolean = accessResponses
|
||||
.filter { it.decision == AccessDecision.DENIED }
|
||||
.any { it.code == code }
|
||||
|
||||
/**
|
||||
* Find and return the response than match with the error code
|
||||
*/
|
||||
fun getErrorCode(code: String): AccessResponse? = accessResponses
|
||||
.firstOrNull { it.decision == AccessDecision.DENIED && it.code == code }
|
||||
|
||||
/**
|
||||
* Get a list of messages of all responses
|
||||
*/
|
||||
fun getMessages(): List<String> = accessResponses
|
||||
.mapNotNull { it.message }
|
||||
|
||||
/**
|
||||
* Get the first message
|
||||
*/
|
||||
fun getFirstMessage(): String? = accessResponses
|
||||
.first()
|
||||
.message
|
||||
}
|
||||
|
||||
/**
|
||||
* The response that all AccessControl method return
|
||||
* @see GrantedResponse
|
||||
* @see DeniedResponse
|
||||
*/
|
||||
sealed class AccessResponse(
|
||||
val decision: AccessDecision,
|
||||
val accessControl: AccessControl,
|
||||
val message: String?,
|
||||
val code: String?
|
||||
) {
|
||||
/**
|
||||
* Convert response as boolean
|
||||
*/
|
||||
fun toBoolean(): Boolean = decision.toBoolean()
|
||||
|
||||
/**
|
||||
* Throw Exception if response if DENIED
|
||||
*/
|
||||
fun assert() {
|
||||
if (this.decision == AccessDecision.DENIED) {
|
||||
throw AccessDeniedException(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GrantedResponse(
|
||||
accessControl: AccessControl,
|
||||
message: String? = null,
|
||||
code: String? = null
|
||||
) : AccessResponse(AccessDecision.GRANTED, accessControl, message, code)
|
||||
|
||||
class DeniedResponse(
|
||||
accessControl: AccessControl,
|
||||
message: String,
|
||||
code: String
|
||||
) : AccessResponse(AccessDecision.DENIED, accessControl, message, code)
|
||||
|
||||
typealias AccessResponses = List<AccessResponse>
|
||||
6
src/main/kotlin/fr/dcproject/common/utils/DateTime.kt
Normal file
6
src/main/kotlin/fr/dcproject/common/utils/DateTime.kt
Normal file
@@ -0,0 +1,6 @@
|
||||
package fr.dcproject.common.utils
|
||||
|
||||
import org.joda.time.DateTime
|
||||
import org.joda.time.format.ISODateTimeFormat
|
||||
|
||||
fun DateTime.toIso(): String = ISODateTimeFormat.dateTime().print(this)
|
||||
29
src/main/kotlin/fr/dcproject/common/utils/Elastic.kt
Normal file
29
src/main/kotlin/fr/dcproject/common/utils/Elastic.kt
Normal file
@@ -0,0 +1,29 @@
|
||||
package fr.dcproject.common.utils
|
||||
|
||||
import com.jayway.jsonpath.JsonPath
|
||||
import com.jayway.jsonpath.PathNotFoundException
|
||||
import org.apache.http.util.EntityUtils
|
||||
import org.elasticsearch.client.Response
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
fun Response.contentToString(): String {
|
||||
return EntityUtils.toString(this.entity)
|
||||
}
|
||||
|
||||
fun Response.getField(jsonPath: String): Int? {
|
||||
return try {
|
||||
JsonPath.read(this.contentToString(), jsonPath)
|
||||
} catch (e: PathNotFoundException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun String.getJsonField(jsonPath: String): Int? {
|
||||
return try {
|
||||
JsonPath.read(this, jsonPath)
|
||||
} catch (e: PathNotFoundException) {
|
||||
LoggerFactory.getLogger("fr.dcproject.utils.getJsonField")
|
||||
.warn("No value for Json path ${JsonPath.compile(jsonPath).path}")
|
||||
null
|
||||
}
|
||||
}
|
||||
10
src/main/kotlin/fr/dcproject/common/utils/LoggerDelegate.kt
Normal file
10
src/main/kotlin/fr/dcproject/common/utils/LoggerDelegate.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package fr.dcproject.common.utils
|
||||
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
internal class LoggerDelegate<in R : Any> : ReadOnlyProperty<R, Logger> {
|
||||
override fun getValue(thisRef: R, property: KProperty<*>): Logger = LoggerFactory.getLogger(thisRef.javaClass.packageName)
|
||||
}
|
||||
23
src/main/kotlin/fr/dcproject/common/utils/Request.kt
Normal file
23
src/main/kotlin/fr/dcproject/common/utils/Request.kt
Normal file
@@ -0,0 +1,23 @@
|
||||
package fr.dcproject.common.utils
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException
|
||||
import io.ktor.application.ApplicationCall
|
||||
import io.ktor.application.log
|
||||
import io.ktor.features.BadRequestException
|
||||
import io.ktor.request.receive
|
||||
import kotlin.reflect.typeOf
|
||||
|
||||
/**
|
||||
* Receives content for this request.
|
||||
* @param type instance of `KClass` specifying type to be received.
|
||||
* @return instance of [T] received from this call, or `null` if content cannot be transformed to the requested type..
|
||||
*/
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
public suspend inline fun <reified T : Any> ApplicationCall.receiveOrBadRequest(message: String = "Bad Request, wrong body request"): T {
|
||||
return try {
|
||||
receive<T>(typeOf<T>())
|
||||
} catch (cause: MissingKotlinParameterException) {
|
||||
application.log.debug("Conversion failed, throw bad exeption", cause)
|
||||
throw BadRequestException(message, cause)
|
||||
}
|
||||
}
|
||||
7
src/main/kotlin/fr/dcproject/common/utils/Resources.kt
Normal file
7
src/main/kotlin/fr/dcproject/common/utils/Resources.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package fr.dcproject.common.utils
|
||||
|
||||
fun String.readResource(callback: (String) -> Unit = {}): String {
|
||||
val content = callback::class.java.getResource(this).readText()
|
||||
callback(content)
|
||||
return content
|
||||
}
|
||||
11
src/main/kotlin/fr/dcproject/common/utils/Uuid.kt
Normal file
11
src/main/kotlin/fr/dcproject/common/utils/Uuid.kt
Normal file
@@ -0,0 +1,11 @@
|
||||
package fr.dcproject.common.utils
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
fun String.toUUID(): UUID = UUID.fromString(this.trim())
|
||||
|
||||
fun List<String?>.toUUID(): List<UUID> = this
|
||||
.filterNotNull()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.map { UUID.fromString(it) }
|
||||
28
src/main/kotlin/fr/dcproject/common/utils/waitElastic.kt
Normal file
28
src/main/kotlin/fr/dcproject/common/utils/waitElastic.kt
Normal file
@@ -0,0 +1,28 @@
|
||||
package fr.dcproject.common.utils
|
||||
|
||||
import org.elasticsearch.client.Request
|
||||
import org.elasticsearch.client.RestClient
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
fun RestClient.waitElasticsearchIsUp() {
|
||||
val logger: Logger = LoggerFactory.getLogger("fr.dcproject.elasticsearch")
|
||||
val request = Request("GET", "/_cluster/health")
|
||||
repeat(5 * 60 / 2) { // 5 minutes
|
||||
runCatching {
|
||||
performRequest(request).statusLine.statusCode
|
||||
}.onSuccess {
|
||||
if (it == 200) {
|
||||
logger.debug("Elasticsearch is Ready! Continue...")
|
||||
return
|
||||
} else {
|
||||
logger.debug("sleep 2s and retry...")
|
||||
Thread.sleep(2000)
|
||||
}
|
||||
}.onFailure {
|
||||
logger.debug("${it.message}, sleep 2s and retry...")
|
||||
Thread.sleep(2000)
|
||||
}
|
||||
}
|
||||
error("Elasticsearch is not ready")
|
||||
}
|
||||
113
src/main/kotlin/fr/dcproject/component/article/Article.kt
Normal file
113
src/main/kotlin/fr/dcproject/component/article/Article.kt
Normal file
@@ -0,0 +1,113 @@
|
||||
package fr.dcproject.component.article
|
||||
|
||||
import fr.dcproject.common.entity.CreatedBy
|
||||
import fr.dcproject.common.entity.TargetI
|
||||
import fr.dcproject.common.entity.TargetRef
|
||||
import fr.dcproject.common.entity.VersionableRef
|
||||
import fr.dcproject.component.citizen.CitizenCart
|
||||
import fr.dcproject.component.citizen.CitizenCartI
|
||||
import fr.dcproject.component.citizen.CitizenI
|
||||
import fr.dcproject.component.citizen.CitizenRef
|
||||
import fr.dcproject.component.opinion.entity.Opinionable
|
||||
import fr.dcproject.component.opinion.entity.Opinions
|
||||
import fr.dcproject.component.vote.entity.Votable
|
||||
import fr.dcproject.component.vote.entity.VotableImp
|
||||
import fr.dcproject.component.workgroup.WorkgroupCart
|
||||
import fr.dcproject.component.workgroup.WorkgroupCartI
|
||||
import fr.dcproject.component.workgroup.WorkgroupRef
|
||||
import fr.dcproject.component.workgroup.WorkgroupSimple
|
||||
import fr.postgresjson.entity.EntityCreatedAt
|
||||
import fr.postgresjson.entity.EntityCreatedAtImp
|
||||
import fr.postgresjson.entity.EntityDeletedAt
|
||||
import fr.postgresjson.entity.EntityDeletedAtImp
|
||||
import fr.postgresjson.entity.EntityVersioning
|
||||
import fr.postgresjson.entity.UuidEntityI
|
||||
import org.joda.time.DateTime
|
||||
import java.util.UUID
|
||||
|
||||
data class ArticleForView(
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
override val title: String,
|
||||
val anonymous: Boolean = true,
|
||||
val content: String,
|
||||
val description: String,
|
||||
val tags: List<String> = emptyList(),
|
||||
override val createdBy: CitizenRef,
|
||||
override val versionNumber: Int = 0,
|
||||
override val versionId: UUID = UUID.randomUUID(),
|
||||
val workgroup: WorkgroupSimple<CitizenRef>? = null,
|
||||
override val opinions: Opinions = emptyMap(),
|
||||
override val draft: Boolean = false,
|
||||
override val deletedAt: DateTime? = null
|
||||
) : ArticleRef(id),
|
||||
ArticleAuthI<CitizenRef>,
|
||||
ArticleWithTitleI,
|
||||
EntityVersioning<UUID, Int>,
|
||||
EntityCreatedAt by EntityCreatedAtImp(),
|
||||
EntityDeletedAt by EntityDeletedAtImp(deletedAt),
|
||||
VersionableRef,
|
||||
Opinionable,
|
||||
Votable by VotableImp() {
|
||||
val lastVersion: Boolean = false
|
||||
}
|
||||
|
||||
interface ArticleForUpdateI<C : CitizenRef> : ArticleI, ArticleWithTitleI, VersionableRef, TargetI, CreatedBy<C> {
|
||||
val anonymous: Boolean
|
||||
val content: String
|
||||
val description: String
|
||||
val draft: Boolean
|
||||
val workgroup: WorkgroupRef?
|
||||
}
|
||||
|
||||
class ArticleForUpdate(
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
override val title: String,
|
||||
override val anonymous: Boolean = true,
|
||||
override val content: String,
|
||||
override val description: String,
|
||||
tags: List<String> = emptyList(),
|
||||
override val draft: Boolean = false,
|
||||
override val createdBy: CitizenRef,
|
||||
override val workgroup: WorkgroupRef? = null,
|
||||
override val versionId: UUID = UUID.randomUUID(),
|
||||
override val deletedAt: DateTime? = null,
|
||||
) : ArticleRef(id),
|
||||
ArticleForUpdateI<CitizenRef>,
|
||||
ArticleAuthI<CitizenRef>,
|
||||
VersionableRef {
|
||||
val tags: List<String> = tags.distinct()
|
||||
}
|
||||
|
||||
class ArticleForListing(
|
||||
id: UUID? = null,
|
||||
override val title: String,
|
||||
override val createdBy: CitizenCart,
|
||||
override val workgroup: WorkgroupCart?,
|
||||
override val deletedAt: DateTime?,
|
||||
override val draft: Boolean
|
||||
) : ArticleForListingI,
|
||||
ArticleRef(id),
|
||||
ArticleAuthI<CitizenCartI>,
|
||||
Votable by VotableImp(),
|
||||
CreatedBy<CitizenCartI>
|
||||
|
||||
interface ArticleForListingI : ArticleWithTitleI, CreatedBy<CitizenCartI> {
|
||||
val workgroup: WorkgroupCartI?
|
||||
}
|
||||
|
||||
open class ArticleRef(
|
||||
id: UUID? = null
|
||||
) : ArticleI, TargetRef(id)
|
||||
|
||||
interface ArticleI : UuidEntityI, TargetI
|
||||
|
||||
interface ArticleWithTitleI : ArticleI {
|
||||
val title: String
|
||||
}
|
||||
|
||||
interface ArticleAuthI<U : CitizenI> :
|
||||
ArticleI,
|
||||
CreatedBy<U>,
|
||||
EntityDeletedAt {
|
||||
val draft: Boolean
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package fr.dcproject.component.article
|
||||
|
||||
import fr.dcproject.common.entity.CreatedBy
|
||||
import fr.dcproject.common.entity.VersionableRef
|
||||
import fr.dcproject.common.security.AccessControl
|
||||
import fr.dcproject.common.security.AccessResponse
|
||||
import fr.dcproject.component.citizen.CitizenI
|
||||
|
||||
class ArticleAccessControl(private val articleRepo: ArticleRepository) : AccessControl() {
|
||||
fun <S : ArticleAuthI<*>> canView(subjects: List<S>, citizen: CitizenI?): AccessResponse =
|
||||
canAll(subjects) { canView(it, citizen) }
|
||||
|
||||
fun <S : ArticleAuthI<*>> canView(subject: S, citizen: CitizenI?): AccessResponse {
|
||||
return if (subject.isDeleted()) denied("Article is deleted", "article.deleted")
|
||||
else if (subject.draft && (citizen == null || subject.createdBy.id != citizen.id)) denied("Article is draft, but it's not yours", "article.draft.not.yours")
|
||||
else granted()
|
||||
}
|
||||
|
||||
fun <S : CreatedBy<*>> canDelete(subject: S, citizen: CitizenI?): AccessResponse {
|
||||
if (citizen == null) return denied("You must be connected to create article", "article.create.notConnected")
|
||||
return if (subject.createdBy.id == citizen.id) {
|
||||
granted()
|
||||
} else {
|
||||
denied("Cannot delete article if is not yours", "article.delete.notYours")
|
||||
}
|
||||
}
|
||||
|
||||
fun <S> canUpsert(subject: S, citizen: CitizenI?): AccessResponse
|
||||
where S : ArticleI,
|
||||
S : CreatedBy<*>,
|
||||
S : VersionableRef {
|
||||
if (citizen == null) return denied("You must be connected to create article", "article.create.notConnected")
|
||||
/* The new Article must by created by the same citizen of the connected citizen */
|
||||
if (subject.createdBy.id == citizen.id) {
|
||||
/* The creator must be the same of the creator of preview version of article */
|
||||
val lastVersionId = articleRepo
|
||||
.findVersionsByVersionId(1, 1, subject.versionId)
|
||||
.result
|
||||
.firstOrNull()?.createdBy?.id
|
||||
|
||||
return when (lastVersionId) {
|
||||
null -> granted("You can create a new Article")
|
||||
citizen.id -> granted("Last version is yours")
|
||||
else -> denied("Last version is not yours", "article.lastVersion.notYours")
|
||||
}
|
||||
}
|
||||
return denied("This article must be yours for update it", "article.update.notYours")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package fr.dcproject.component.article
|
||||
|
||||
import fr.postgresjson.connexion.Paginated
|
||||
import fr.postgresjson.connexion.Requester
|
||||
import fr.postgresjson.entity.Parameter
|
||||
import fr.postgresjson.repository.RepositoryI
|
||||
import net.pearx.kasechange.toSnakeCase
|
||||
import java.util.UUID
|
||||
|
||||
class ArticleRepository(override var requester: Requester) : RepositoryI {
|
||||
fun findById(id: UUID): ArticleForView? {
|
||||
val function = requester.getFunction("find_article_by_id")
|
||||
return function.selectOne("id" to id)
|
||||
}
|
||||
|
||||
fun findVersionsById(page: Int = 1, limit: Int = 50, id: UUID): Paginated<ArticleForView> {
|
||||
return requester
|
||||
.getFunction("find_articles_versions_by_id")
|
||||
.select(page, limit, "id" to id)
|
||||
}
|
||||
|
||||
fun findVersionsByVersionId(page: Int = 1, limit: Int = 50, versionId: UUID): Paginated<ArticleForView> {
|
||||
return requester
|
||||
.getFunction("find_articles_versions_by_version_id")
|
||||
.select(page, limit, "version_id" to versionId)
|
||||
}
|
||||
|
||||
fun find(
|
||||
page: Int = 1,
|
||||
limit: Int = 50,
|
||||
sort: String? = null,
|
||||
direction: RepositoryI.Direction? = null,
|
||||
search: String? = null,
|
||||
filter: Filter = Filter()
|
||||
): Paginated<ArticleForListing> {
|
||||
return requester
|
||||
.getFunction("find_articles")
|
||||
.select(
|
||||
page,
|
||||
limit,
|
||||
"sort" to sort?.toSnakeCase(),
|
||||
"direction" to direction,
|
||||
"search" to search,
|
||||
"filter" to filter
|
||||
)
|
||||
}
|
||||
|
||||
fun upsert(article: ArticleForUpdate): ArticleForView? {
|
||||
return requester
|
||||
.getFunction("upsert_article")
|
||||
.selectOne("resource" to article)
|
||||
}
|
||||
|
||||
class Filter(
|
||||
val createdById: String? = null,
|
||||
val workgroupId: String? = null
|
||||
) : Parameter
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package fr.dcproject.component.article
|
||||
|
||||
import fr.dcproject.common.entity.VersionableRef
|
||||
import fr.dcproject.common.utils.contentToString
|
||||
import fr.dcproject.common.utils.getJsonField
|
||||
import fr.dcproject.common.utils.toIso
|
||||
import fr.dcproject.component.citizen.CitizenI
|
||||
import fr.dcproject.component.views.ViewManager
|
||||
import fr.dcproject.component.views.entity.ViewAggregation
|
||||
import org.elasticsearch.client.Request
|
||||
import org.elasticsearch.client.Response
|
||||
import org.elasticsearch.client.RestClient
|
||||
import org.joda.time.DateTime
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Wrapper for manage views with elasticsearch
|
||||
*/
|
||||
class ArticleViewManager <A> (private val restClient: RestClient) : ViewManager<A> where A : VersionableRef, A : ArticleI {
|
||||
/**
|
||||
* Add view on article to elasticsearch
|
||||
*/
|
||||
override fun addView(ip: String, entity: A, citizen: CitizenI?, dateTime: DateTime): Response? {
|
||||
val isLogged = (citizen != null).toString()
|
||||
val ref = citizen?.id ?: UUID.nameUUIDFromBytes(ip.toByteArray())!!
|
||||
val request = Request(
|
||||
"POST",
|
||||
"/views/_doc/"
|
||||
).apply {
|
||||
//language=JSON
|
||||
setJsonEntity(
|
||||
"""
|
||||
{
|
||||
"logged": $isLogged,
|
||||
"type": "article",
|
||||
"user_ref": "$ref",
|
||||
"ip": "$ip",
|
||||
"id": "${entity.id}",
|
||||
"version_id": "${entity.versionId}",
|
||||
"citizen_id": "${citizen?.id}",
|
||||
"view_at": "${dateTime.toIso()}"
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
return restClient.performRequest(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get article views aggregations from elasticsearch
|
||||
*/
|
||||
override fun getViewsCount(entity: A): ViewAggregation {
|
||||
val request = Request(
|
||||
"GET",
|
||||
"/views/_search"
|
||||
).apply {
|
||||
//language=JSON
|
||||
setJsonEntity(
|
||||
"""
|
||||
{
|
||||
"size": 0,
|
||||
"query": {
|
||||
"bool": {
|
||||
"must": {
|
||||
"term": {
|
||||
"version_id": "${entity.versionId}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"aggs" : {
|
||||
"total": {
|
||||
"composite" : {
|
||||
"sources" : [
|
||||
{ "version_id": { "terms": {"field": "version_id" } } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"unique" : {
|
||||
"cardinality" : {
|
||||
"field" : "user_ref",
|
||||
"precision_threshold": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
return restClient
|
||||
.performRequest(request).contentToString().run {
|
||||
ViewAggregation(
|
||||
getJsonField("$.aggregations.total.buckets[0].doc_count") ?: 0,
|
||||
getJsonField("$.aggregations.unique.value") ?: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package fr.dcproject.component.article
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val articleKoinModule = module {
|
||||
single { ArticleRepository(get()) }
|
||||
single { ArticleAccessControl(get()) }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package fr.dcproject.component.article.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.article.ArticleAccessControl
|
||||
import fr.dcproject.component.article.ArticleRef
|
||||
import fr.dcproject.component.article.ArticleRepository
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.postgresjson.repository.RepositoryI
|
||||
import io.ktor.application.call
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object FindArticleVersions {
|
||||
@Location("/articles/{article}/versions")
|
||||
class ArticleVersionsRequest(
|
||||
article: UUID,
|
||||
page: Int = 1,
|
||||
limit: Int = 50,
|
||||
val sort: String? = null,
|
||||
val direction: RepositoryI.Direction? = null,
|
||||
val search: String? = null
|
||||
) {
|
||||
val page: Int = if (page < 1) 1 else page
|
||||
val limit: Int = if (limit > 50) 50 else if (limit < 1) 1 else limit
|
||||
val article = ArticleRef(article)
|
||||
}
|
||||
|
||||
private fun ArticleRepository.findVersions(request: ArticleVersionsRequest) =
|
||||
findVersionsById(request.page, request.limit, request.article.id)
|
||||
|
||||
fun Route.findArticleVersions(repo: ArticleRepository, ac: ArticleAccessControl) {
|
||||
get<ArticleVersionsRequest> {
|
||||
repo.findVersions(it)
|
||||
.apply { ac.assert { canView(result, citizenOrNull) } }
|
||||
.let { call.respond(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package fr.dcproject.component.article.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.article.ArticleAccessControl
|
||||
import fr.dcproject.component.article.ArticleForListing
|
||||
import fr.dcproject.component.article.ArticleRepository
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.routes.PaginatedRequest
|
||||
import fr.dcproject.routes.PaginatedRequestI
|
||||
import fr.postgresjson.connexion.Paginated
|
||||
import fr.postgresjson.repository.RepositoryI
|
||||
import io.ktor.application.call
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object FindArticles {
|
||||
@Location("/articles")
|
||||
class ArticlesRequest(
|
||||
page: Int = 1,
|
||||
limit: Int = 50,
|
||||
val sort: String? = null,
|
||||
val direction: RepositoryI.Direction? = null,
|
||||
val search: String? = null,
|
||||
val createdBy: String? = null,
|
||||
val workgroup: String? = null
|
||||
) : PaginatedRequestI by PaginatedRequest(page, limit)
|
||||
|
||||
private fun ArticleRepository.findArticles(request: ArticlesRequest): Paginated<ArticleForListing> {
|
||||
return find(
|
||||
request.page,
|
||||
request.limit,
|
||||
request.sort,
|
||||
request.direction,
|
||||
request.search,
|
||||
ArticleRepository.Filter(createdById = request.createdBy, workgroupId = request.workgroup)
|
||||
)
|
||||
}
|
||||
|
||||
fun Route.findArticles(repo: ArticleRepository, ac: ArticleAccessControl) {
|
||||
get<ArticlesRequest> {
|
||||
repo.findArticles(it)
|
||||
.apply { ac.assert { canView(result, citizenOrNull) } }
|
||||
.let { call.respond(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package fr.dcproject.component.article.routes
|
||||
|
||||
import fr.dcproject.common.dto.CreatedAt
|
||||
import fr.dcproject.common.dto.Versionable
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.article.ArticleAccessControl
|
||||
import fr.dcproject.component.article.ArticleForView
|
||||
import fr.dcproject.component.article.ArticleRef
|
||||
import fr.dcproject.component.article.ArticleRepository
|
||||
import fr.dcproject.component.article.ArticleViewManager
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.opinion.dto.Opinionable
|
||||
import fr.dcproject.component.views.dto.Viewable
|
||||
import fr.dcproject.component.views.entity.ViewAggregation
|
||||
import fr.dcproject.component.vote.dto.Votable
|
||||
import io.ktor.application.call
|
||||
import io.ktor.features.NotFoundException
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object GetOneArticle {
|
||||
@Location("/articles/{article}")
|
||||
class ArticleRequest(article: UUID) {
|
||||
val article = ArticleRef(article)
|
||||
}
|
||||
|
||||
class Output(
|
||||
article: ArticleForView,
|
||||
views: ViewAggregation = ViewAggregation()
|
||||
) : CreatedAt by CreatedAt.Imp(article),
|
||||
Opinionable by Opinionable.Imp(article),
|
||||
Votable by Votable.Imp(article),
|
||||
Versionable by Versionable.Imp(article),
|
||||
Viewable by Viewable.Imp(views) {
|
||||
val id = article.id
|
||||
val title = article.title
|
||||
val anonymous = article.anonymous
|
||||
val content = article.content
|
||||
val description = article.description
|
||||
val tags = article.tags
|
||||
val draft = article.draft
|
||||
val lastVersion = article.lastVersion
|
||||
val createdBy = article.createdBy
|
||||
val workgroup = article.workgroup // TODO change to workgroup DTO
|
||||
}
|
||||
|
||||
fun Route.getOneArticle(viewManager: ArticleViewManager<ArticleForView>, ac: ArticleAccessControl, repo: ArticleRepository) {
|
||||
get<ArticleRequest> {
|
||||
val article: ArticleForView = repo.findById(it.article.id) ?: throw NotFoundException("Article ${it.article.id} not found")
|
||||
ac.assert { canView(article, citizenOrNull) }
|
||||
|
||||
Output(
|
||||
article,
|
||||
viewManager.getViewsCount(article)
|
||||
).also { out ->
|
||||
call.respond(out)
|
||||
}
|
||||
|
||||
launch {
|
||||
viewManager.addView(call.request.local.remoteHost, article, citizenOrNull)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package fr.dcproject.component.article.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.common.utils.receiveOrBadRequest
|
||||
import fr.dcproject.component.article.ArticleAccessControl
|
||||
import fr.dcproject.component.article.ArticleForUpdate
|
||||
import fr.dcproject.component.article.ArticleForView
|
||||
import fr.dcproject.component.article.ArticleRepository
|
||||
import fr.dcproject.component.article.routes.UpsertArticle.UpsertArticleRequest.Input
|
||||
import fr.dcproject.component.auth.citizen
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.notification.ArticleUpdateNotification
|
||||
import fr.dcproject.component.notification.Publisher
|
||||
import fr.dcproject.component.workgroup.WorkgroupRef
|
||||
import io.ktor.application.ApplicationCall
|
||||
import io.ktor.application.call
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.post
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object UpsertArticle {
|
||||
@Location("/articles")
|
||||
class UpsertArticleRequest {
|
||||
class Input(
|
||||
val id: UUID?,
|
||||
val title: String,
|
||||
val anonymous: Boolean = true,
|
||||
val content: String,
|
||||
val description: String,
|
||||
val tags: List<String> = emptyList(),
|
||||
val draft: Boolean = false,
|
||||
val versionId: UUID,
|
||||
val workgroup: WorkgroupRef? = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun Route.upsertArticle(repo: ArticleRepository, publisher: Publisher, ac: ArticleAccessControl) {
|
||||
suspend fun ApplicationCall.convertRequestToEntity(): ArticleForUpdate = receiveOrBadRequest<Input>().run {
|
||||
ArticleForUpdate(
|
||||
id = id ?: UUID.randomUUID(),
|
||||
title = title,
|
||||
anonymous = anonymous,
|
||||
content = content,
|
||||
description = description,
|
||||
tags = tags,
|
||||
draft = draft,
|
||||
createdBy = citizen,
|
||||
workgroup = workgroup,
|
||||
versionId = versionId
|
||||
)
|
||||
}
|
||||
|
||||
post<UpsertArticleRequest> {
|
||||
val article = call.convertRequestToEntity()
|
||||
ac.assert { canUpsert(article, citizenOrNull) }
|
||||
val newArticle: ArticleForView = repo.upsert(article) ?: error("Article not updated")
|
||||
call.respond(newArticle)
|
||||
publisher.publish(ArticleUpdateNotification(newArticle))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package fr.dcproject.component.article.routes
|
||||
|
||||
import fr.dcproject.component.article.routes.FindArticleVersions.findArticleVersions
|
||||
import fr.dcproject.component.article.routes.FindArticles.findArticles
|
||||
import fr.dcproject.component.article.routes.GetOneArticle.getOneArticle
|
||||
import fr.dcproject.component.article.routes.UpsertArticle.upsertArticle
|
||||
import io.ktor.auth.authenticate
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.routing.Routing
|
||||
import org.koin.ktor.ext.get
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
fun Routing.installArticleRoutes() {
|
||||
authenticate(optional = true) {
|
||||
findArticles(get(), get())
|
||||
findArticleVersions(get(), get())
|
||||
getOneArticle(get(), get(), get())
|
||||
upsertArticle(get(), get(), get())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package fr.dcproject.component.auth
|
||||
|
||||
import fr.dcproject.component.citizen.CitizenRepository
|
||||
import io.ktor.application.ApplicationCall
|
||||
import io.ktor.auth.authentication
|
||||
import io.ktor.util.AttributeKey
|
||||
import io.ktor.util.pipeline.PipelineContext
|
||||
import org.koin.core.context.GlobalContext
|
||||
import fr.dcproject.component.citizen.Citizen as CitizenEntity
|
||||
|
||||
class ForbiddenException(message: String) : Exception(message)
|
||||
|
||||
private val citizenAttributeKey = AttributeKey<CitizenEntity>("CitizenContext")
|
||||
|
||||
val ApplicationCall.citizen: CitizenEntity
|
||||
get() = attributes.computeIfAbsent(citizenAttributeKey) {
|
||||
val user = authentication.principal<UserI>() ?: throw ForbiddenException("No User Connected")
|
||||
GlobalContext.get().koin.get<CitizenRepository>().findByUser(user)
|
||||
?: throw ForbiddenException("Citizen not found for this user id \"${user.id}\"")
|
||||
}
|
||||
|
||||
val ApplicationCall.citizenOrNull: CitizenEntity?
|
||||
get() = authentication.principal<UserI>()?.let {
|
||||
GlobalContext.get().koin.get<CitizenRepository>().findByUser(it)
|
||||
}
|
||||
|
||||
val PipelineContext<Unit, ApplicationCall>.citizen get() = context.citizen
|
||||
val PipelineContext<Unit, ApplicationCall>.citizenOrNull get() = context.citizenOrNull
|
||||
|
||||
val ApplicationCall.user get() = authentication.principal<User>()
|
||||
14
src/main/kotlin/fr/dcproject/component/auth/KoinModule.kt
Normal file
14
src/main/kotlin/fr/dcproject/component/auth/KoinModule.kt
Normal file
@@ -0,0 +1,14 @@
|
||||
package fr.dcproject.component.auth
|
||||
|
||||
import fr.dcproject.application.Configuration
|
||||
import fr.dcproject.common.email.Mailer
|
||||
import org.koin.dsl.module
|
||||
|
||||
val authKoinModule = module {
|
||||
single { UserRepository(get()) }
|
||||
// Used to send a connexion link by email
|
||||
single {
|
||||
val config: Configuration = get()
|
||||
PasswordlessAuth(get<Mailer>(), config.domain, get())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package fr.dcproject.component.auth
|
||||
|
||||
import com.sendgrid.helpers.mail.Mail
|
||||
import com.sendgrid.helpers.mail.objects.Content
|
||||
import com.sendgrid.helpers.mail.objects.Email
|
||||
import fr.dcproject.common.email.Mailer
|
||||
import fr.dcproject.component.auth.jwt.makeToken
|
||||
import fr.dcproject.component.citizen.CitizenRepository
|
||||
import fr.dcproject.component.citizen.CitizenWithEmail
|
||||
import fr.dcproject.component.citizen.CitizenWithUserI
|
||||
import io.ktor.http.URLBuilder
|
||||
|
||||
/**
|
||||
* Send a connexion link by email
|
||||
*/
|
||||
class PasswordlessAuth(
|
||||
private val mailer: Mailer,
|
||||
private val domain: String,
|
||||
private val citizenRepo: CitizenRepository
|
||||
) {
|
||||
fun sendEmail(email: String, url: String) {
|
||||
val citizen = citizenRepo.findByEmail(email) ?: noEmail(email)
|
||||
sendEmail(citizen, url)
|
||||
}
|
||||
|
||||
fun <C> sendEmail(citizen: C, url: String) where C : CitizenWithEmail, C : CitizenWithUserI {
|
||||
mailer.sendEmail {
|
||||
val token = citizen.user.makeToken()
|
||||
Mail(
|
||||
Email("passwordless-auth@$domain"),
|
||||
"Connection",
|
||||
Email(citizen.email),
|
||||
Content("text/plain", generateContent(token, url))
|
||||
).apply {
|
||||
addContent(Content("text/html", generateHtmlContent(token, url)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateHtmlContent(token: String, url: String): String? {
|
||||
val urlObject = URLBuilder(url)
|
||||
urlObject.parameters.append("token", token)
|
||||
return "Click <a href=\"${urlObject.buildString()}\">here</a> for connect to $domain"
|
||||
}
|
||||
|
||||
private fun generateContent(token: String, url: String): String {
|
||||
val urlObject = URLBuilder(url)
|
||||
urlObject.parameters.append("token", token)
|
||||
return "Copy this link into your browser for connect to $domain: \n${urlObject.buildString()}"
|
||||
}
|
||||
|
||||
class EmailNotFound(val email: String) : Exception() {
|
||||
override val message: String = "No Citizen with this email : $email"
|
||||
}
|
||||
|
||||
private fun noEmail(email: String): Nothing = throw EmailNotFound(email)
|
||||
}
|
||||
54
src/main/kotlin/fr/dcproject/component/auth/User.kt
Normal file
54
src/main/kotlin/fr/dcproject/component/auth/User.kt
Normal file
@@ -0,0 +1,54 @@
|
||||
package fr.dcproject.component.auth
|
||||
|
||||
import fr.dcproject.component.auth.UserI.Roles
|
||||
import fr.postgresjson.entity.EntityCreatedAt
|
||||
import fr.postgresjson.entity.EntityCreatedAtImp
|
||||
import fr.postgresjson.entity.EntityUpdatedAt
|
||||
import fr.postgresjson.entity.EntityUpdatedAtImp
|
||||
import fr.postgresjson.entity.UuidEntity
|
||||
import fr.postgresjson.entity.UuidEntityI
|
||||
import io.ktor.auth.Principal
|
||||
import org.joda.time.DateTime
|
||||
import java.util.UUID
|
||||
|
||||
class UserForCreate(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
username: String,
|
||||
override val password: String,
|
||||
blockedAt: DateTime? = null,
|
||||
roles: List<Roles> = emptyList()
|
||||
) : User(id, username, blockedAt, roles),
|
||||
UserWithPasswordI
|
||||
|
||||
open class User(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
var username: String,
|
||||
var blockedAt: DateTime? = null,
|
||||
var roles: List<Roles> = emptyList()
|
||||
) : UserRef(id),
|
||||
EntityCreatedAt by EntityCreatedAtImp(),
|
||||
EntityUpdatedAt by EntityUpdatedAtImp()
|
||||
|
||||
interface UserWithPasswordI {
|
||||
val id: UUID
|
||||
val password: String
|
||||
}
|
||||
|
||||
class UserWithPassword(
|
||||
id: UUID,
|
||||
override val password: String,
|
||||
) : UserWithPasswordI,
|
||||
UserRef(id)
|
||||
|
||||
open class UserRef(
|
||||
id: UUID = UUID.randomUUID()
|
||||
) : UserI, UuidEntity(id)
|
||||
|
||||
interface UserI : UuidEntityI, Principal {
|
||||
enum class Roles { ROLE_USER, ROLE_ADMIN }
|
||||
}
|
||||
|
||||
interface UserForAuthI : UserI {
|
||||
var roles: List<Roles>
|
||||
var blockedAt: DateTime?
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package fr.dcproject.component.auth
|
||||
|
||||
import fr.postgresjson.connexion.Requester
|
||||
import fr.postgresjson.repository.RepositoryI
|
||||
import io.ktor.auth.UserPasswordCredential
|
||||
import java.util.UUID
|
||||
|
||||
class UserRepository(override var requester: Requester) : RepositoryI {
|
||||
fun findByCredentials(credentials: UserPasswordCredential): User? {
|
||||
return requester
|
||||
.getFunction("check_user")
|
||||
.selectOne(
|
||||
"username" to credentials.name,
|
||||
"password" to credentials.password
|
||||
)
|
||||
}
|
||||
|
||||
fun findById(id: UUID): User {
|
||||
return requester
|
||||
.getFunction("find_user_by_id")
|
||||
.selectOne(
|
||||
"id" to id
|
||||
) ?: throw UserNotFound(id)
|
||||
}
|
||||
|
||||
fun insert(user: User): User? {
|
||||
return requester
|
||||
.getFunction("insert_user")
|
||||
.selectOne("resource" to user)
|
||||
}
|
||||
|
||||
fun changePassword(user: UserWithPassword) {
|
||||
requester
|
||||
.getFunction("change_user_password")
|
||||
.sendQuery("resource" to user)
|
||||
}
|
||||
|
||||
class UserNotFound(override val message: String?, override val cause: Throwable?) : Throwable(message, cause) {
|
||||
constructor(id: UUID) : this("No User with ID $id", null)
|
||||
}
|
||||
}
|
||||
14
src/main/kotlin/fr/dcproject/component/auth/jwt/JWTMaker.kt
Normal file
14
src/main/kotlin/fr/dcproject/component/auth/jwt/JWTMaker.kt
Normal file
@@ -0,0 +1,14 @@
|
||||
package fr.dcproject.component.auth.jwt
|
||||
|
||||
import com.auth0.jwt.JWT
|
||||
import fr.dcproject.component.auth.UserI
|
||||
|
||||
/**
|
||||
* Produce a token for this combination of User and Account
|
||||
*/
|
||||
fun UserI.makeToken(): String = JWT.create()
|
||||
.withSubject("Authentication")
|
||||
.withIssuer(JwtConfig.issuer)
|
||||
.withClaim("id", id.toString())
|
||||
.withExpiresAt(JwtConfig.getExpiration())
|
||||
.sign(JwtConfig.algorithm)
|
||||
25
src/main/kotlin/fr/dcproject/component/auth/jwt/JwtConfig.kt
Normal file
25
src/main/kotlin/fr/dcproject/component/auth/jwt/JwtConfig.kt
Normal file
@@ -0,0 +1,25 @@
|
||||
package fr.dcproject.component.auth.jwt
|
||||
|
||||
import com.auth0.jwt.JWT
|
||||
import com.auth0.jwt.JWTVerifier
|
||||
import com.auth0.jwt.algorithms.Algorithm
|
||||
import java.util.Date
|
||||
|
||||
object JwtConfig {
|
||||
private const val secret = "zAP5MBA4B4Ijz0MZaS48"
|
||||
const val issuer = "dc-project.fr"
|
||||
private const val validityInMs = 3_600_000 * 10 // 10 hours
|
||||
|
||||
// TODO change to RSA512
|
||||
val algorithm: Algorithm = Algorithm.HMAC512(secret)
|
||||
|
||||
val verifier: JWTVerifier = JWT
|
||||
.require(algorithm)
|
||||
.withIssuer(issuer)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Calculate the expiration Date based on current time + the given validity
|
||||
*/
|
||||
fun getExpiration() = Date(System.currentTimeMillis() + validityInMs)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package fr.dcproject.component.auth.jwt
|
||||
|
||||
import fr.dcproject.component.auth.User
|
||||
import fr.dcproject.component.auth.UserRepository
|
||||
import io.ktor.application.ApplicationCall
|
||||
import io.ktor.auth.Authentication
|
||||
import io.ktor.auth.jwt.jwt
|
||||
import io.ktor.http.auth.HttpAuthHeader
|
||||
import io.ktor.routing.Routing
|
||||
import java.util.UUID
|
||||
|
||||
fun jwtInstallation(userRepo: UserRepository): Authentication.Configuration.() -> Unit = {
|
||||
/**
|
||||
* Setup the JWT authentication to be used in [Routing].
|
||||
* If the token is valid, the corresponding [User] is fetched from the database.
|
||||
* The [User] can then be accessed in each [ApplicationCall].
|
||||
*/
|
||||
jwt {
|
||||
verifier(JwtConfig.verifier)
|
||||
realm = "dc-project.fr"
|
||||
validate {
|
||||
it.payload.getClaim("id").asString()?.let { id ->
|
||||
userRepo.findById(UUID.fromString(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Token in URL */
|
||||
jwt("url") {
|
||||
verifier(JwtConfig.verifier)
|
||||
realm = "dc-project.fr"
|
||||
authHeader { call ->
|
||||
call.request.queryParameters["token"]?.let {
|
||||
HttpAuthHeader.Single("Bearer", it)
|
||||
}
|
||||
}
|
||||
validate {
|
||||
it.payload.getClaim("id").asString()?.let { id ->
|
||||
userRepo.findById(UUID.fromString(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/main/kotlin/fr/dcproject/component/auth/routes/Login.kt
Normal file
43
src/main/kotlin/fr/dcproject/component/auth/routes/Login.kt
Normal file
@@ -0,0 +1,43 @@
|
||||
package fr.dcproject.component.auth.routes
|
||||
|
||||
import com.fasterxml.jackson.databind.exc.MismatchedInputException
|
||||
import fr.dcproject.common.utils.receiveOrBadRequest
|
||||
import fr.dcproject.component.auth.UserRepository
|
||||
import fr.dcproject.component.auth.jwt.makeToken
|
||||
import fr.dcproject.component.auth.routes.Login.LoginRequest.Input
|
||||
import io.ktor.application.call
|
||||
import io.ktor.auth.UserPasswordCredential
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.post
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.response.respondText
|
||||
import io.ktor.routing.Route
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object Login {
|
||||
@Location("/login")
|
||||
class LoginRequest {
|
||||
data class Input(
|
||||
val username: String,
|
||||
val password: String,
|
||||
)
|
||||
}
|
||||
|
||||
fun Route.authLogin(userRepo: UserRepository) {
|
||||
post<LoginRequest> {
|
||||
try {
|
||||
val credentials = call.receiveOrBadRequest<Input>().run {
|
||||
UserPasswordCredential(username, password)
|
||||
}
|
||||
|
||||
userRepo.findByCredentials(credentials)?.let { user ->
|
||||
call.respondText(user.makeToken())
|
||||
} ?: call.respond(HttpStatusCode.BadRequest, "Username not exist or password is wrong")
|
||||
} catch (e: MismatchedInputException) {
|
||||
call.respond(HttpStatusCode.BadRequest, "You must be send name and password to the request")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package fr.dcproject.component.auth.routes
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException
|
||||
import fr.dcproject.common.utils.receiveOrBadRequest
|
||||
import fr.dcproject.component.auth.User
|
||||
import fr.dcproject.component.auth.UserForCreate
|
||||
import fr.dcproject.component.auth.UserI
|
||||
import fr.dcproject.component.auth.jwt.makeToken
|
||||
import fr.dcproject.component.auth.routes.Register.RegisterRequest.Input
|
||||
import fr.dcproject.component.citizen.CitizenForCreate
|
||||
import fr.dcproject.component.citizen.CitizenI
|
||||
import fr.dcproject.component.citizen.CitizenRepository
|
||||
import io.ktor.application.call
|
||||
import io.ktor.features.BadRequestException
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.post
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.response.respondText
|
||||
import io.ktor.routing.Route
|
||||
import org.joda.time.DateTime
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object Register {
|
||||
@Location("/register")
|
||||
class RegisterRequest {
|
||||
data class Input(
|
||||
val name: Name,
|
||||
val email: String,
|
||||
val birthday: DateTime,
|
||||
val voteAnonymous: Boolean = true,
|
||||
val followAnonymous: Boolean = true,
|
||||
val user: User
|
||||
) {
|
||||
data class Name(
|
||||
val firstName: String,
|
||||
val lastName: String,
|
||||
val civility: String? = null
|
||||
)
|
||||
data class User(
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.authRegister(citizenRepo: CitizenRepository) {
|
||||
fun Input.toCitizen(): CitizenForCreate = CitizenForCreate(
|
||||
name = CitizenI.Name(name.firstName, name.lastName, name.civility),
|
||||
birthday = birthday,
|
||||
email = email,
|
||||
followAnonymous = followAnonymous,
|
||||
voteAnonymous = voteAnonymous,
|
||||
user = UserForCreate(
|
||||
username = user.username,
|
||||
password = user.password,
|
||||
roles = listOf(UserI.Roles.ROLE_USER)
|
||||
)
|
||||
)
|
||||
|
||||
post<RegisterRequest> {
|
||||
try {
|
||||
val citizen = call.receiveOrBadRequest<Input>().toCitizen()
|
||||
val createdCitizen = citizenRepo.insertWithUser(citizen)?.user ?: throw BadRequestException("Bad request")
|
||||
call.respondText(createdCitizen.makeToken())
|
||||
} catch (e: MissingKotlinParameterException) {
|
||||
call.respond(HttpStatusCode.BadRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/main/kotlin/fr/dcproject/component/auth/routes/Sso.kt
Normal file
36
src/main/kotlin/fr/dcproject/component/auth/routes/Sso.kt
Normal file
@@ -0,0 +1,36 @@
|
||||
package fr.dcproject.component.auth.routes
|
||||
|
||||
import fr.dcproject.common.utils.receiveOrBadRequest
|
||||
import fr.dcproject.component.auth.PasswordlessAuth
|
||||
import fr.dcproject.component.auth.routes.Sso.PasswordlessRequest.Input
|
||||
import io.ktor.application.call
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.post
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object Sso {
|
||||
@Location("/auth/passwordless")
|
||||
class PasswordlessRequest {
|
||||
data class Input(val email: String, val url: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email to the citizen with a link to automatically connect
|
||||
*/
|
||||
fun Route.authPasswordless(passwordlessAuth: PasswordlessAuth) {
|
||||
post<PasswordlessRequest> {
|
||||
call.receiveOrBadRequest<Input>().run {
|
||||
try {
|
||||
passwordlessAuth.sendEmail(email, url)
|
||||
} catch (e: PasswordlessAuth.EmailNotFound) {
|
||||
call.respond(HttpStatusCode.NotFound)
|
||||
}
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package fr.dcproject.component.auth.routes
|
||||
|
||||
import fr.dcproject.component.auth.routes.Login.authLogin
|
||||
import fr.dcproject.component.auth.routes.Register.authRegister
|
||||
import fr.dcproject.component.auth.routes.Sso.authPasswordless
|
||||
import io.ktor.auth.authenticate
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.routing.Routing
|
||||
import org.koin.ktor.ext.get
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
fun Routing.installAuthRoutes() {
|
||||
authenticate(optional = true) {
|
||||
authLogin(get())
|
||||
authRegister(get())
|
||||
authPasswordless(get())
|
||||
}
|
||||
}
|
||||
130
src/main/kotlin/fr/dcproject/component/citizen/Citizen.kt
Normal file
130
src/main/kotlin/fr/dcproject/component/citizen/Citizen.kt
Normal file
@@ -0,0 +1,130 @@
|
||||
package fr.dcproject.component.citizen
|
||||
|
||||
import fr.dcproject.component.auth.User
|
||||
import fr.dcproject.component.auth.UserForCreate
|
||||
import fr.dcproject.component.auth.UserI
|
||||
import fr.dcproject.component.auth.UserRef
|
||||
import fr.dcproject.component.citizen.CitizenI.Name
|
||||
import fr.dcproject.component.workgroup.WorkgroupSimple
|
||||
import fr.postgresjson.entity.EntityCreatedAt
|
||||
import fr.postgresjson.entity.EntityCreatedAtImp
|
||||
import fr.postgresjson.entity.EntityDeletedAt
|
||||
import fr.postgresjson.entity.EntityDeletedAtImp
|
||||
import fr.postgresjson.entity.UuidEntity
|
||||
import fr.postgresjson.entity.UuidEntityI
|
||||
import org.joda.time.DateTime
|
||||
import java.util.UUID
|
||||
|
||||
class CitizenForCreate(
|
||||
val name: Name,
|
||||
val email: String,
|
||||
val birthday: DateTime,
|
||||
val voteAnonymous: Boolean = true,
|
||||
val followAnonymous: Boolean = true,
|
||||
override val user: UserForCreate,
|
||||
id: UUID = UUID.randomUUID(),
|
||||
) : CitizenI,
|
||||
CitizenRefWithUser(id, user),
|
||||
EntityCreatedAt by EntityCreatedAtImp()
|
||||
|
||||
class Citizen(
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
override val name: Name,
|
||||
override val email: String,
|
||||
override val birthday: DateTime,
|
||||
override val voteAnonymous: Boolean = true,
|
||||
override val followAnonymous: Boolean = true,
|
||||
override val user: User,
|
||||
deletedAt: DateTime? = null
|
||||
) : CitizenFull,
|
||||
CitizenBasicI,
|
||||
CitizenRef(id),
|
||||
CitizenCartI,
|
||||
EntityCreatedAt by EntityCreatedAtImp(),
|
||||
EntityDeletedAt by EntityDeletedAtImp(deletedAt) {
|
||||
var workgroups: List<WorkgroupAndRoles> = emptyList()
|
||||
|
||||
class WorkgroupAndRoles(
|
||||
val roles: List<String>,
|
||||
val workgroup: WorkgroupSimple<CitizenRef>
|
||||
)
|
||||
}
|
||||
|
||||
@Deprecated("")
|
||||
data class CitizenBasic(
|
||||
override var id: UUID = UUID.randomUUID(),
|
||||
override var name: Name,
|
||||
override var email: String,
|
||||
override var birthday: DateTime,
|
||||
override var voteAnonymous: Boolean = true,
|
||||
override var followAnonymous: Boolean = true,
|
||||
override val user: User,
|
||||
override val deletedAt: DateTime? = null
|
||||
) : CitizenBasicI,
|
||||
CitizenRefWithUser(id, user),
|
||||
EntityDeletedAt by EntityDeletedAtImp(deletedAt)
|
||||
|
||||
@Deprecated("")
|
||||
open class CitizenSimple(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
var name: Name,
|
||||
user: UserRef
|
||||
) : CitizenRefWithUser(id, user)
|
||||
|
||||
class CitizenCart(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
override val name: Name,
|
||||
override val user: UserRef
|
||||
) : CitizenRef(id),
|
||||
CitizenCartI
|
||||
|
||||
interface CitizenCartI : CitizenI, CitizenWithUserI {
|
||||
val name: Name
|
||||
}
|
||||
|
||||
open class CitizenRefWithUser(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
override val user: UserRef
|
||||
) : CitizenWithUserI,
|
||||
CitizenRef(id)
|
||||
|
||||
open class CitizenRef(
|
||||
id: UUID = UUID.randomUUID()
|
||||
) : UuidEntity(id),
|
||||
CitizenI
|
||||
|
||||
interface CitizenI : UuidEntityI {
|
||||
data class Name(
|
||||
override val firstName: String,
|
||||
override val lastName: String,
|
||||
override val civility: String? = null
|
||||
) : NameI
|
||||
|
||||
interface NameI {
|
||||
val firstName: String
|
||||
val lastName: String
|
||||
val civility: String?
|
||||
fun getFullName(): String = "${civility ?: ""} $firstName $lastName".trim()
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("")
|
||||
interface CitizenBasicI : CitizenWithUserI, CitizenWithEmail, EntityDeletedAt {
|
||||
val name: Name
|
||||
val birthday: DateTime
|
||||
val voteAnonymous: Boolean
|
||||
val followAnonymous: Boolean
|
||||
}
|
||||
|
||||
@Deprecated("")
|
||||
interface CitizenFull : CitizenBasicI {
|
||||
override val user: User
|
||||
}
|
||||
|
||||
interface CitizenWithUserI : CitizenI {
|
||||
val user: UserI
|
||||
}
|
||||
|
||||
interface CitizenWithEmail : CitizenI {
|
||||
val email: String
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package fr.dcproject.component.citizen
|
||||
|
||||
import fr.dcproject.common.security.AccessControl
|
||||
import fr.dcproject.common.security.AccessResponse
|
||||
import fr.postgresjson.entity.EntityDeletedAt
|
||||
|
||||
class CitizenAccessControl : AccessControl() {
|
||||
fun <S> canView(subjects: List<S>, connectedCitizen: CitizenI?): AccessResponse where S : CitizenI, S : EntityDeletedAt =
|
||||
canAll(subjects) { canView(it, connectedCitizen) }
|
||||
|
||||
fun <S> canView(subject: S, connectedCitizen: CitizenI?): AccessResponse where S : CitizenI, S : EntityDeletedAt {
|
||||
if (connectedCitizen == null) return denied("You must be connected to view citizen", "citizen.view.connected")
|
||||
return if (subject.isDeleted()) denied("You cannot view a deleted citizen", "citizen.view.deleted")
|
||||
else granted()
|
||||
}
|
||||
|
||||
fun <S : CitizenI> canUpdate(subject: S, connectedCitizen: CitizenI?): AccessResponse {
|
||||
if (connectedCitizen == null) return denied("You must be connected to update Citizen", "citizen.update.notConnected")
|
||||
return if (subject.id == connectedCitizen.id) granted() else denied("You can only update your citizen", "citizen.update.notYours")
|
||||
}
|
||||
|
||||
fun <S : CitizenI> canChangePassword(subject: S, connectedCitizen: CitizenI?): AccessResponse {
|
||||
if (connectedCitizen == null) return denied("You must be connected to change your password", "citizen.changePassword.notConnected")
|
||||
return if (subject.id == connectedCitizen.id) granted() else denied("You can only change your password", "citizen.password.notYours")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package fr.dcproject.component.citizen
|
||||
|
||||
import fr.dcproject.component.auth.UserI
|
||||
import fr.postgresjson.connexion.Paginated
|
||||
import fr.postgresjson.connexion.Requester
|
||||
import fr.postgresjson.repository.RepositoryI
|
||||
import net.pearx.kasechange.toSnakeCase
|
||||
import java.util.UUID
|
||||
|
||||
class CitizenRepository(override var requester: Requester) : RepositoryI {
|
||||
fun findById(id: UUID): Citizen? = requester
|
||||
.getFunction("find_citizen_by_id_with_user_and_workgroups")
|
||||
.selectOne("id" to id)
|
||||
|
||||
fun findByUser(user: UserI): Citizen? = requester
|
||||
.getFunction("find_citizen_by_user_id")
|
||||
.selectOne("user_id" to user.id)
|
||||
|
||||
fun findByUsername(unsername: String): Citizen? = requester
|
||||
.getFunction("find_citizen_by_username")
|
||||
.selectOne("username" to unsername)
|
||||
|
||||
fun findByEmail(email: String): Citizen? = requester
|
||||
.getFunction("find_citizen_by_email")
|
||||
.selectOne("email" to email)
|
||||
|
||||
fun find(
|
||||
page: Int = 1,
|
||||
limit: Int = 50,
|
||||
sort: String? = null,
|
||||
direction: RepositoryI.Direction? = null,
|
||||
search: String? = null
|
||||
): Paginated<CitizenBasic> = requester
|
||||
.getFunction("find_citizens")
|
||||
.select(
|
||||
page,
|
||||
limit,
|
||||
"sort" to sort?.toSnakeCase(),
|
||||
"direction" to direction,
|
||||
"search" to search
|
||||
)
|
||||
|
||||
fun upsert(citizen: Citizen): Citizen? = requester
|
||||
.getFunction("upsert_citizen")
|
||||
.selectOne("resource" to citizen)
|
||||
|
||||
fun insertWithUser(citizen: CitizenForCreate): Citizen? = requester
|
||||
.getFunction("insert_citizen_with_user")
|
||||
.selectOne("resource" to citizen)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package fr.dcproject.component.citizen
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val citizenKoinModule = module {
|
||||
single { CitizenRepository(get()) }
|
||||
single { CitizenAccessControl() }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package fr.dcproject.component.citizen.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.common.utils.receiveOrBadRequest
|
||||
import fr.dcproject.component.auth.UserRepository
|
||||
import fr.dcproject.component.auth.UserWithPassword
|
||||
import fr.dcproject.component.auth.citizen
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.citizen.CitizenAccessControl
|
||||
import fr.dcproject.component.citizen.CitizenRef
|
||||
import io.ktor.application.call
|
||||
import io.ktor.auth.UserPasswordCredential
|
||||
import io.ktor.features.BadRequestException
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.put
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object ChangeMyPassword {
|
||||
@Location("/citizens/{citizen}/password/change")
|
||||
class ChangePasswordCitizenRequest(citizen: UUID) {
|
||||
val citizen = CitizenRef(citizen)
|
||||
data class Input(val oldPassword: String, val newPassword: String)
|
||||
}
|
||||
|
||||
fun Route.changeMyPassword(ac: CitizenAccessControl, userRepository: UserRepository) {
|
||||
put<ChangePasswordCitizenRequest> {
|
||||
ac.assert { canChangePassword(it.citizen, citizenOrNull) }
|
||||
val content = call.receiveOrBadRequest<ChangePasswordCitizenRequest.Input>()
|
||||
userRepository.findByCredentials(UserPasswordCredential(citizen.user.username, content.oldPassword)) ?: throw BadRequestException("Bad Password")
|
||||
userRepository.changePassword(
|
||||
UserWithPassword(
|
||||
citizen.user.id,
|
||||
content.newPassword,
|
||||
)
|
||||
)
|
||||
|
||||
call.respond(HttpStatusCode.Created)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package fr.dcproject.component.citizen.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.citizen.CitizenAccessControl
|
||||
import fr.dcproject.component.citizen.CitizenRepository
|
||||
import fr.dcproject.routes.PaginatedRequest
|
||||
import fr.dcproject.routes.PaginatedRequestI
|
||||
import fr.postgresjson.repository.RepositoryI
|
||||
import io.ktor.application.call
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object FindCitizens {
|
||||
@Location("/citizens")
|
||||
class CitizensRequest(
|
||||
page: Int = 1,
|
||||
limit: Int = 50,
|
||||
val sort: String? = null,
|
||||
val direction: RepositoryI.Direction? = null,
|
||||
val search: String? = null
|
||||
) : PaginatedRequestI by PaginatedRequest(page, limit)
|
||||
|
||||
fun Route.findCitizen(ac: CitizenAccessControl, repo: CitizenRepository) {
|
||||
get<CitizensRequest> {
|
||||
val citizens = repo.find(it.page, it.limit, it.sort, it.direction, it.search)
|
||||
ac.assert { canView(citizens.result, citizenOrNull) }
|
||||
call.respond(citizens)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package fr.dcproject.component.citizen.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.auth.citizen
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.citizen.CitizenAccessControl
|
||||
import io.ktor.application.call
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object GetCurrentCitizen {
|
||||
@Location("/citizens/current")
|
||||
class CurrentCitizenRequest
|
||||
|
||||
fun Route.getCurrentCitizen(ac: CitizenAccessControl) {
|
||||
get<CurrentCitizenRequest> {
|
||||
val currentUser = citizenOrNull
|
||||
if (currentUser === null) {
|
||||
call.respond(HttpStatusCode.Unauthorized)
|
||||
} else {
|
||||
ac.assert { canView(currentUser, citizenOrNull) }
|
||||
call.respond(citizen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package fr.dcproject.component.citizen.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.citizen.CitizenAccessControl
|
||||
import fr.dcproject.component.citizen.CitizenRef
|
||||
import fr.dcproject.component.citizen.CitizenRepository
|
||||
import io.ktor.application.call
|
||||
import io.ktor.features.NotFoundException
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object GetOneCitizen {
|
||||
@Location("/citizens/{citizen}")
|
||||
class CitizenRequest(citizen: UUID) {
|
||||
val citizen = CitizenRef(citizen)
|
||||
}
|
||||
|
||||
fun Route.getOneCitizen(ac: CitizenAccessControl, citizenRepository: CitizenRepository) {
|
||||
get<CitizenRequest> {
|
||||
val citizen = citizenRepository.findById(it.citizen.id) ?: throw NotFoundException("Citizen not found ${it.citizen.id}")
|
||||
ac.assert { canView(citizen, citizenOrNull) }
|
||||
|
||||
call.respond(it.citizen)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package fr.dcproject.component.citizen.routes
|
||||
|
||||
import fr.dcproject.component.citizen.routes.ChangeMyPassword.changeMyPassword
|
||||
import fr.dcproject.component.citizen.routes.FindCitizens.findCitizen
|
||||
import fr.dcproject.component.citizen.routes.GetCurrentCitizen.getCurrentCitizen
|
||||
import fr.dcproject.component.citizen.routes.GetOneCitizen.getOneCitizen
|
||||
import io.ktor.auth.authenticate
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.routing.Routing
|
||||
import org.koin.ktor.ext.get
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
fun Routing.installCitizenRoutes() {
|
||||
authenticate(optional = true) {
|
||||
findCitizen(get(), get())
|
||||
getOneCitizen(get(), get())
|
||||
getCurrentCitizen(get())
|
||||
changeMyPassword(get(), get())
|
||||
}
|
||||
}
|
||||
14
src/main/kotlin/fr/dcproject/component/comment/KoinModule.kt
Normal file
14
src/main/kotlin/fr/dcproject/component/comment/KoinModule.kt
Normal file
@@ -0,0 +1,14 @@
|
||||
package fr.dcproject.component.comment
|
||||
|
||||
import fr.dcproject.component.comment.article.CommentArticleRepository
|
||||
import fr.dcproject.component.comment.constitution.CommentConstitutionRepository
|
||||
import fr.dcproject.component.comment.generic.CommentAccessControl
|
||||
import fr.dcproject.component.comment.generic.CommentRepository
|
||||
import org.koin.dsl.module
|
||||
|
||||
val commentKoinModule = module {
|
||||
single { CommentRepository(get()) }
|
||||
single { CommentArticleRepository(get()) }
|
||||
single { CommentConstitutionRepository(get()) }
|
||||
single { CommentAccessControl() }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package fr.dcproject.component.comment.article
|
||||
|
||||
import fr.dcproject.common.entity.TargetI
|
||||
import fr.dcproject.component.article.ArticleForView
|
||||
import fr.dcproject.component.article.ArticleRef
|
||||
import fr.dcproject.component.citizen.CitizenI
|
||||
import fr.dcproject.component.citizen.CitizenRef
|
||||
import fr.dcproject.component.comment.generic.CommentForView
|
||||
import fr.dcproject.component.comment.generic.CommentRepositoryAbs
|
||||
import fr.postgresjson.connexion.Paginated
|
||||
import fr.postgresjson.connexion.Requester
|
||||
import fr.postgresjson.entity.UuidEntityI
|
||||
import java.util.UUID
|
||||
|
||||
class CommentArticleRepository(requester: Requester) : CommentRepositoryAbs<ArticleForView>(requester) {
|
||||
override fun findById(id: UUID): CommentForView<ArticleForView, CitizenRef>? {
|
||||
return requester
|
||||
.getFunction("find_comment_by_id")
|
||||
.selectOne(mapOf("id" to id))
|
||||
}
|
||||
|
||||
override fun findByCitizen(
|
||||
citizen: CitizenI,
|
||||
page: Int,
|
||||
limit: Int
|
||||
): Paginated<CommentForView<ArticleForView, CitizenRef>> {
|
||||
return requester.run {
|
||||
getFunction("find_comments_by_citizen")
|
||||
.select(
|
||||
page,
|
||||
limit,
|
||||
"created_by_id" to citizen.id,
|
||||
"reference" to TargetI.getReference(ArticleRef::class)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun findByTarget(
|
||||
target: UuidEntityI,
|
||||
page: Int,
|
||||
limit: Int,
|
||||
sort: Sort
|
||||
): Paginated<CommentForView<ArticleForView, CitizenRef>> = requester
|
||||
.getFunction("find_comments_by_target")
|
||||
.select(
|
||||
page,
|
||||
limit,
|
||||
"target_id" to target.id,
|
||||
"sort" to sort.sql
|
||||
)
|
||||
|
||||
enum class Sort(val sql: String) {
|
||||
CREATED_AT("created_at"),
|
||||
VOTES("votes");
|
||||
|
||||
companion object {
|
||||
fun fromString(string: String): Sort? {
|
||||
return values().firstOrNull { it.sql == string }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package fr.dcproject.component.comment.article.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.common.utils.receiveOrBadRequest
|
||||
import fr.dcproject.component.article.ArticleRef
|
||||
import fr.dcproject.component.auth.citizen
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.comment.article.CommentArticleRepository
|
||||
import fr.dcproject.component.comment.article.routes.CreateCommentArticle.PostArticleCommentRequest.Input
|
||||
import fr.dcproject.component.comment.generic.CommentAccessControl
|
||||
import fr.dcproject.component.comment.generic.CommentForUpdate
|
||||
import io.ktor.application.ApplicationCall
|
||||
import io.ktor.application.call
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.post
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object CreateCommentArticle {
|
||||
@Location("/articles/{article}/comments")
|
||||
class PostArticleCommentRequest(article: UUID) {
|
||||
val article = ArticleRef(article)
|
||||
class Input(val content: String)
|
||||
}
|
||||
|
||||
suspend fun PostArticleCommentRequest.getComment(call: ApplicationCall) = call.receiveOrBadRequest<Input>().run {
|
||||
CommentForUpdate(
|
||||
target = article,
|
||||
createdBy = call.citizen,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
fun Route.createCommentArticle(repo: CommentArticleRepository, ac: CommentAccessControl) {
|
||||
post<PostArticleCommentRequest> {
|
||||
it.getComment(call).let { comment ->
|
||||
ac.assert { canCreate(comment, citizenOrNull) }
|
||||
repo.comment(comment)
|
||||
call.respond(HttpStatusCode.Created, comment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package fr.dcproject.component.comment.article.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.article.ArticleRef
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.comment.article.CommentArticleRepository
|
||||
import fr.dcproject.component.comment.generic.CommentAccessControl
|
||||
import fr.dcproject.routes.PaginatedRequest
|
||||
import fr.dcproject.routes.PaginatedRequestI
|
||||
import io.ktor.application.call
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object GetArticleComments {
|
||||
@Location("/articles/{article}/comments")
|
||||
class ArticleCommentsRequest(
|
||||
article: UUID,
|
||||
page: Int = 1,
|
||||
limit: Int = 50,
|
||||
val search: String? = null,
|
||||
sort: String = CommentArticleRepository.Sort.CREATED_AT.sql
|
||||
) : PaginatedRequestI by PaginatedRequest(page, limit) {
|
||||
val article = ArticleRef(article)
|
||||
val sort: CommentArticleRepository.Sort = CommentArticleRepository.Sort.fromString(sort) ?: CommentArticleRepository.Sort.CREATED_AT
|
||||
}
|
||||
|
||||
fun Route.getArticleComments(repo: CommentArticleRepository, ac: CommentAccessControl) {
|
||||
get<ArticleCommentsRequest> {
|
||||
val comment = repo.findByTarget(it.article, it.page, it.limit, it.sort)
|
||||
if (comment.result.isNotEmpty()) {
|
||||
ac.assert { canView(comment.result, citizenOrNull) }
|
||||
}
|
||||
call.respond(HttpStatusCode.OK, comment)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package fr.dcproject.component.comment.article.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.citizen.CitizenRef
|
||||
import fr.dcproject.component.comment.article.CommentArticleRepository
|
||||
import fr.dcproject.component.comment.generic.CommentAccessControl
|
||||
import io.ktor.application.call
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object GetCitizenArticleComments {
|
||||
@Location("/citizens/{citizen}/comments/articles")
|
||||
class CitizenCommentArticleRequest(citizen: UUID) {
|
||||
val citizen = CitizenRef(citizen)
|
||||
}
|
||||
|
||||
fun Route.getCitizenArticleComments(repo: CommentArticleRepository, ac: CommentAccessControl) {
|
||||
get<CitizenCommentArticleRequest> {
|
||||
repo.findByCitizen(it.citizen).let { comments ->
|
||||
ac.assert { canView(comments.result, citizenOrNull) }
|
||||
call.respond(comments)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package fr.dcproject.component.comment.article.routes
|
||||
|
||||
import fr.dcproject.component.comment.article.routes.CreateCommentArticle.createCommentArticle
|
||||
import fr.dcproject.component.comment.article.routes.GetArticleComments.getArticleComments
|
||||
import fr.dcproject.component.comment.article.routes.GetCitizenArticleComments.getCitizenArticleComments
|
||||
import io.ktor.auth.authenticate
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.routing.Routing
|
||||
import org.koin.ktor.ext.get
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
fun Routing.installCommentArticleRoutes() {
|
||||
authenticate(optional = true) {
|
||||
getArticleComments(get(), get())
|
||||
createCommentArticle(get(), get())
|
||||
getCitizenArticleComments(get(), get())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package fr.dcproject.component.comment.constitution
|
||||
|
||||
import fr.dcproject.common.entity.TargetI
|
||||
import fr.dcproject.component.citizen.CitizenI
|
||||
import fr.dcproject.component.citizen.CitizenRef
|
||||
import fr.dcproject.component.comment.article.CommentArticleRepository
|
||||
import fr.dcproject.component.comment.generic.CommentForView
|
||||
import fr.dcproject.component.comment.generic.CommentRepositoryAbs
|
||||
import fr.dcproject.component.constitution.ConstitutionRef
|
||||
import fr.postgresjson.connexion.Paginated
|
||||
import fr.postgresjson.connexion.Requester
|
||||
import fr.postgresjson.entity.UuidEntityI
|
||||
import java.util.UUID
|
||||
|
||||
class CommentConstitutionRepository(requester: Requester) : CommentRepositoryAbs<ConstitutionRef>(requester) {
|
||||
override fun findById(id: UUID): CommentForView<ConstitutionRef, CitizenRef>? {
|
||||
return requester
|
||||
.getFunction("find_comment_by_id")
|
||||
.selectOne(mapOf("id" to id))
|
||||
}
|
||||
|
||||
override fun findByCitizen(
|
||||
citizen: CitizenI,
|
||||
page: Int,
|
||||
limit: Int
|
||||
): Paginated<CommentForView<ConstitutionRef, CitizenRef>> {
|
||||
return requester.run {
|
||||
getFunction("find_comments_by_citizen")
|
||||
.select(
|
||||
page,
|
||||
limit,
|
||||
"created_by_id" to citizen.id,
|
||||
"reference" to TargetI.getReference(ConstitutionRef::class)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun findByTarget(
|
||||
target: UuidEntityI,
|
||||
page: Int,
|
||||
limit: Int,
|
||||
sort: CommentArticleRepository.Sort
|
||||
): Paginated<CommentForView<ConstitutionRef, CitizenRef>> {
|
||||
return requester.run {
|
||||
getFunction("find_comments_by_target")
|
||||
.select(
|
||||
page,
|
||||
limit,
|
||||
"target_id" to target.id,
|
||||
"sort" to sort.sql
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package fr.dcproject.component.comment.constitution.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.auth.citizen
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.comment.constitution.CommentConstitutionRepository
|
||||
import fr.dcproject.component.comment.generic.CommentAccessControl
|
||||
import fr.dcproject.component.comment.generic.CommentForUpdate
|
||||
import fr.dcproject.component.constitution.ConstitutionRef
|
||||
import io.ktor.application.call
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.post
|
||||
import io.ktor.request.receiveText
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object CreateConstitutionComment {
|
||||
@Location("/constitutions/{constitution}/comments")
|
||||
class CreateConstitutionCommentRequest(constitution: UUID) {
|
||||
val constitution = ConstitutionRef(constitution)
|
||||
}
|
||||
|
||||
fun Route.createConstitutionComment(repo: CommentConstitutionRepository, ac: CommentAccessControl) {
|
||||
post<CreateConstitutionCommentRequest> {
|
||||
val content = call.receiveText()
|
||||
val comment = CommentForUpdate(
|
||||
target = it.constitution,
|
||||
createdBy = citizen,
|
||||
content = content
|
||||
)
|
||||
ac.assert { canCreate(comment, citizenOrNull) }
|
||||
repo.comment(comment)
|
||||
|
||||
call.respond(HttpStatusCode.Created, comment)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package fr.dcproject.component.comment.constitution.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.citizen.CitizenRef
|
||||
import fr.dcproject.component.comment.constitution.CommentConstitutionRepository
|
||||
import fr.dcproject.component.comment.generic.CommentAccessControl
|
||||
import io.ktor.application.call
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object GetCitizenCommentConstitution {
|
||||
@Location("/citizens/{citizen}/comments/constitutions")
|
||||
class GetCitizenCommentConstitutionRequest(citizen: UUID) {
|
||||
val citizen = CitizenRef(citizen)
|
||||
}
|
||||
|
||||
fun Route.getCitizenCommentConstitution(repo: CommentConstitutionRepository, ac: CommentAccessControl) {
|
||||
get<GetCitizenCommentConstitutionRequest> {
|
||||
val comments = repo.findByCitizen(it.citizen)
|
||||
ac.assert { canView(comments.result, citizenOrNull) }
|
||||
call.respond(comments)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package fr.dcproject.component.comment.constitution.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.comment.constitution.CommentConstitutionRepository
|
||||
import fr.dcproject.component.comment.generic.CommentAccessControl
|
||||
import fr.dcproject.component.constitution.ConstitutionRef
|
||||
import io.ktor.application.call
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object GetConstitutionComment {
|
||||
@Location("/constitutions/{constitution}/comments")
|
||||
class GetConstitutionCommentRequest(constitution: UUID) {
|
||||
val constitution = ConstitutionRef(constitution)
|
||||
}
|
||||
|
||||
fun Route.getConstitutionComment(repo: CommentConstitutionRepository, ac: CommentAccessControl) {
|
||||
get<GetConstitutionCommentRequest> {
|
||||
val comments = repo.findByTarget(it.constitution)
|
||||
ac.assert { canView(comments.result, citizenOrNull) }
|
||||
call.respond(HttpStatusCode.OK, comments)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package fr.dcproject.component.comment.constitution.routes
|
||||
|
||||
import fr.dcproject.component.comment.constitution.routes.CreateConstitutionComment.createConstitutionComment
|
||||
import fr.dcproject.component.comment.constitution.routes.GetCitizenCommentConstitution.getCitizenCommentConstitution
|
||||
import fr.dcproject.component.comment.constitution.routes.GetConstitutionComment.getConstitutionComment
|
||||
import io.ktor.auth.authenticate
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.routing.Routing
|
||||
import org.koin.ktor.ext.get
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
fun Routing.installCommentConstitutionRoutes() {
|
||||
authenticate(optional = true) {
|
||||
createConstitutionComment(get(), get())
|
||||
getCitizenCommentConstitution(get(), get())
|
||||
getConstitutionComment(get(), get())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package fr.dcproject.component.comment.generic
|
||||
|
||||
import fr.dcproject.common.entity.EntityI
|
||||
import fr.dcproject.common.entity.ExtraI
|
||||
import fr.dcproject.common.entity.HasTarget
|
||||
import fr.dcproject.common.entity.TargetI
|
||||
import fr.dcproject.common.entity.TargetRef
|
||||
import fr.dcproject.component.citizen.CitizenRef
|
||||
import fr.dcproject.component.vote.entity.Votable
|
||||
import fr.dcproject.component.vote.entity.VotableImp
|
||||
import fr.postgresjson.entity.EntityCreatedAt
|
||||
import fr.postgresjson.entity.EntityCreatedAtImp
|
||||
import fr.postgresjson.entity.EntityCreatedBy
|
||||
import fr.postgresjson.entity.EntityCreatedByImp
|
||||
import fr.postgresjson.entity.EntityDeletedAt
|
||||
import fr.postgresjson.entity.EntityDeletedAtImp
|
||||
import fr.postgresjson.entity.EntityUpdatedAt
|
||||
import fr.postgresjson.entity.EntityUpdatedAtImp
|
||||
import org.joda.time.DateTime
|
||||
import java.util.UUID
|
||||
|
||||
class CommentForView<T : TargetI, C : CitizenRef>(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
override val createdBy: C,
|
||||
override val target: T,
|
||||
override var content: String,
|
||||
override val parent: CommentParent<T>? = null,
|
||||
val childrenCount: Int? = null,
|
||||
override val deletedAt: DateTime? = null
|
||||
) : ExtraI<T, C>,
|
||||
CommentWithParentI<T>,
|
||||
CommentForUpdate<T, C>(id, createdBy, target, content, parent, deletedAt),
|
||||
CommentWithTargetI<T>,
|
||||
EntityCreatedBy<C> by EntityCreatedByImp(createdBy),
|
||||
EntityUpdatedAt by EntityUpdatedAtImp(),
|
||||
EntityDeletedAt by EntityDeletedAtImp(),
|
||||
Votable by VotableImp(),
|
||||
TargetI {
|
||||
constructor(
|
||||
createdBy: C,
|
||||
parent: CommentParent<T>,
|
||||
content: String
|
||||
) : this(
|
||||
createdBy = createdBy,
|
||||
parent = parent,
|
||||
target = parent.target,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
open class CommentForUpdate<T : TargetI, C : CitizenRef>(
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
override val createdBy: C,
|
||||
override val target: T,
|
||||
open var content: String,
|
||||
override val parent: CommentParent<T>? = null,
|
||||
override val deletedAt: DateTime? = null
|
||||
) : CommentParent<T>(id, deletedAt, target),
|
||||
CommentWithParentI<T>,
|
||||
ExtraI<T, C>,
|
||||
CommentWithTargetI<T>,
|
||||
EntityCreatedAt by EntityCreatedAtImp(),
|
||||
EntityCreatedBy<C>,
|
||||
EntityDeletedAt,
|
||||
TargetI {
|
||||
constructor(
|
||||
createdBy: C,
|
||||
parent: CommentParent<T>,
|
||||
content: String
|
||||
) : this(
|
||||
createdBy = createdBy,
|
||||
parent = parent,
|
||||
target = parent.target,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
open class CommentParent<T : TargetI>(
|
||||
override val id: UUID,
|
||||
override val deletedAt: DateTime?,
|
||||
override val target: T
|
||||
) : CommentRef(id),
|
||||
CommentParentI<T>
|
||||
|
||||
interface CommentParentI<T : TargetI> : CommentI, EntityDeletedAt, CommentWithTargetI<T>
|
||||
|
||||
interface CommentWithTargetI<T : TargetI> : CommentI, TargetI, HasTarget<T>
|
||||
|
||||
interface CommentWithParentI<T : TargetI> {
|
||||
val parent: CommentParent<T>?
|
||||
}
|
||||
|
||||
open class CommentRef(id: UUID = UUID.randomUUID()) : CommentI, TargetRef(id)
|
||||
|
||||
interface CommentI : EntityI
|
||||
@@ -0,0 +1,41 @@
|
||||
package fr.dcproject.component.comment.generic
|
||||
|
||||
import fr.dcproject.common.entity.HasTarget
|
||||
import fr.dcproject.common.security.AccessControl
|
||||
import fr.dcproject.common.security.AccessResponse
|
||||
import fr.dcproject.component.citizen.CitizenI
|
||||
import fr.postgresjson.entity.EntityCreatedBy
|
||||
import fr.postgresjson.entity.EntityDeletedAt
|
||||
|
||||
class CommentAccessControl : AccessControl() {
|
||||
fun <S> canView(subjects: List<S>, citizen: CitizenI?): AccessResponse
|
||||
where S : CommentI,
|
||||
S : EntityDeletedAt = canAll(subjects) { canView(it, citizen) }
|
||||
|
||||
fun <S> canView(subject: S, citizen: CitizenI?): AccessResponse
|
||||
where S : CommentI,
|
||||
S : EntityDeletedAt = when {
|
||||
subject.isDeleted() -> denied("Your cannot view a deleted comment", "comment.view.deleted")
|
||||
else -> granted()
|
||||
}
|
||||
|
||||
fun <S, CR : CitizenI> canCreate(subject: S, citizen: CitizenI?): AccessResponse
|
||||
where S : CommentI,
|
||||
S : EntityCreatedBy<CR>,
|
||||
S : CommentWithParentI<*>,
|
||||
S : HasTarget<*> = when {
|
||||
citizen == null -> denied("You must be connected to create user", "comment.create.notConnected")
|
||||
subject.createdBy.id != citizen.id -> denied("You cannot create a comment with other user than yours", "comment.create.wrongUser")
|
||||
subject.parent?.isDeleted() ?: false -> denied("You cannot create a comment on deleted parent", "comment.create.deletedParent")
|
||||
subject.target.let { it is EntityDeletedAt && it.isDeleted() } -> denied("You cannot create a comment on deleted target", "comment.create.deletedTarget")
|
||||
else -> granted()
|
||||
}
|
||||
|
||||
fun <S, CR : CitizenI> canUpdate(subject: S, citizen: CitizenI?): AccessResponse
|
||||
where S : CommentI,
|
||||
S : EntityCreatedBy<CR> = when {
|
||||
citizen == null -> denied("You must be connected to update comment", "comment.update.notConnected")
|
||||
citizen.id != subject.createdBy.id -> denied("You cannot update another user of yours", "comment.update.notYours")
|
||||
else -> granted()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package fr.dcproject.component.comment.generic
|
||||
|
||||
import fr.dcproject.common.entity.TargetI
|
||||
import fr.dcproject.common.entity.TargetRef
|
||||
import fr.dcproject.component.citizen.CitizenI
|
||||
import fr.dcproject.component.citizen.CitizenRef
|
||||
import fr.dcproject.component.comment.article.CommentArticleRepository
|
||||
import fr.postgresjson.connexion.Paginated
|
||||
import fr.postgresjson.connexion.Requester
|
||||
import fr.postgresjson.entity.UuidEntityI
|
||||
import fr.postgresjson.repository.RepositoryI
|
||||
import java.util.UUID
|
||||
|
||||
abstract class CommentRepositoryAbs<T : TargetI>(override var requester: Requester) : RepositoryI {
|
||||
abstract fun findById(id: UUID): CommentForView<T, CitizenRef>?
|
||||
|
||||
abstract fun findByCitizen(
|
||||
citizen: CitizenI,
|
||||
page: Int = 1,
|
||||
limit: Int = 50
|
||||
): Paginated<CommentForView<T, CitizenRef>>
|
||||
|
||||
open fun findByParent(
|
||||
parent: CommentForView<T, CitizenRef>,
|
||||
page: Int = 1,
|
||||
limit: Int = 50
|
||||
): Paginated<CommentForView<T, CitizenRef>> {
|
||||
return findByParent(parent.id, page, limit)
|
||||
}
|
||||
|
||||
open fun findByParent(
|
||||
parentId: UUID,
|
||||
page: Int = 1,
|
||||
limit: Int = 50
|
||||
): Paginated<CommentForView<T, CitizenRef>> {
|
||||
return requester.run {
|
||||
getFunction("find_comments_by_parent")
|
||||
.select(
|
||||
page,
|
||||
limit,
|
||||
"parent_id" to parentId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
open fun findByTarget(
|
||||
target: UuidEntityI,
|
||||
page: Int = 1,
|
||||
limit: Int = 50,
|
||||
sort: CommentArticleRepository.Sort = CommentArticleRepository.Sort.CREATED_AT
|
||||
): Paginated<CommentForView<T, CitizenRef>> {
|
||||
return findByTarget(target.id, page, limit, sort)
|
||||
}
|
||||
|
||||
open fun findByTarget(
|
||||
targetId: UUID,
|
||||
page: Int = 1,
|
||||
limit: Int = 50,
|
||||
sort: CommentArticleRepository.Sort = CommentArticleRepository.Sort.CREATED_AT
|
||||
): Paginated<CommentForView<T, CitizenRef>> {
|
||||
return requester.run {
|
||||
getFunction("find_comments_by_target")
|
||||
.select(
|
||||
page,
|
||||
limit,
|
||||
"target_id" to targetId,
|
||||
"sort" to sort.sql
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun <I : TargetI, C : CitizenRef> comment(comment: CommentForUpdate<I, C>) {
|
||||
requester
|
||||
.getFunction("comment")
|
||||
.sendQuery(
|
||||
"reference" to comment.target.reference,
|
||||
"resource" to comment
|
||||
)
|
||||
}
|
||||
|
||||
fun <I : T> edit(comment: CommentForUpdate<I, CitizenRef>) {
|
||||
requester
|
||||
.getFunction("edit_comment")
|
||||
.sendQuery(
|
||||
"id" to comment.id,
|
||||
"content" to comment.content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class CommentRepository(requester: Requester) : CommentRepositoryAbs<TargetRef>(requester) {
|
||||
override fun findById(id: UUID): CommentForView<TargetRef, CitizenRef>? {
|
||||
return requester
|
||||
.getFunction("find_comment_by_id")
|
||||
.selectOne(mapOf("id" to id))
|
||||
}
|
||||
|
||||
override fun findByCitizen(
|
||||
citizen: CitizenI,
|
||||
page: Int,
|
||||
limit: Int
|
||||
): Paginated<CommentForView<TargetRef, CitizenRef>> {
|
||||
return requester.run {
|
||||
getFunction("find_comments_by_citizen")
|
||||
.select(
|
||||
page,
|
||||
limit,
|
||||
"created_by_id" to citizen.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun findByParent(
|
||||
parentId: UUID,
|
||||
page: Int,
|
||||
limit: Int
|
||||
): Paginated<CommentForView<TargetRef, CitizenRef>> {
|
||||
return requester.run {
|
||||
getFunction("find_comments_by_parent")
|
||||
.select(
|
||||
page,
|
||||
limit,
|
||||
"parent_id" to parentId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package fr.dcproject.component.comment.generic.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.common.utils.receiveOrBadRequest
|
||||
import fr.dcproject.component.auth.citizen
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.comment.generic.CommentAccessControl
|
||||
import fr.dcproject.component.comment.generic.CommentForUpdate
|
||||
import fr.dcproject.component.comment.generic.CommentRef
|
||||
import fr.dcproject.component.comment.generic.CommentRepository
|
||||
import io.ktor.application.call
|
||||
import io.ktor.features.NotFoundException
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.post
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object CreateCommentChildren {
|
||||
@Location("/comments/{comment}/children")
|
||||
class CreateCommentChildrenRequest(comment: UUID) {
|
||||
val comment = CommentRef(comment)
|
||||
class Input(val content: String)
|
||||
}
|
||||
|
||||
fun Route.createCommentChildren(repo: CommentRepository, ac: CommentAccessControl) {
|
||||
post<CreateCommentChildrenRequest> {
|
||||
val parent = repo.findById(it.comment.id) ?: throw NotFoundException("Comment not found")
|
||||
val newComment = CommentForUpdate(
|
||||
content = call.receiveOrBadRequest<CreateCommentChildrenRequest.Input>().content,
|
||||
createdBy = citizen,
|
||||
parent = parent
|
||||
)
|
||||
|
||||
ac.assert { canCreate(newComment, citizenOrNull) }
|
||||
repo.comment(newComment)
|
||||
|
||||
call.respond(HttpStatusCode.Created, newComment)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package fr.dcproject.component.comment.generic.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.comment.generic.CommentAccessControl
|
||||
import fr.dcproject.component.comment.generic.CommentRef
|
||||
import fr.dcproject.component.comment.generic.CommentRepository
|
||||
import io.ktor.application.call
|
||||
import io.ktor.features.NotFoundException
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.put
|
||||
import io.ktor.request.receiveText
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object EditComment {
|
||||
@Location("/comments/{comment}")
|
||||
class EditCommentRequest(comment: UUID) {
|
||||
val comment = CommentRef(comment)
|
||||
}
|
||||
|
||||
fun Route.editComment(repo: CommentRepository, ac: CommentAccessControl) {
|
||||
put<EditCommentRequest> {
|
||||
val comment = repo.findById(it.comment.id) ?: throw NotFoundException("Comment not found")
|
||||
ac.assert { canUpdate(comment, citizenOrNull) }
|
||||
|
||||
comment.content = call.receiveText()
|
||||
repo.edit(comment)
|
||||
|
||||
call.respond(HttpStatusCode.OK, comment)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package fr.dcproject.component.comment.generic.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.comment.generic.CommentAccessControl
|
||||
import fr.dcproject.component.comment.generic.CommentRepository
|
||||
import fr.dcproject.routes.PaginatedRequest
|
||||
import fr.dcproject.routes.PaginatedRequestI
|
||||
import io.ktor.application.call
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object GetCommentChildren {
|
||||
@Location("/comments/{comment}/children")
|
||||
class CommentChildrenRequest(
|
||||
val comment: UUID,
|
||||
page: Int = 1,
|
||||
limit: Int = 50,
|
||||
val search: String? = null
|
||||
) : PaginatedRequestI by PaginatedRequest(page, limit)
|
||||
|
||||
fun Route.getChildrenComments(repo: CommentRepository, ac: CommentAccessControl) {
|
||||
get<CommentChildrenRequest> {
|
||||
val comments =
|
||||
repo.findByParent(
|
||||
it.comment,
|
||||
it.page,
|
||||
it.limit
|
||||
)
|
||||
|
||||
ac.assert { canView(comments.result, citizenOrNull) }
|
||||
|
||||
call.respond(HttpStatusCode.OK, comments)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package fr.dcproject.component.comment.generic.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.comment.generic.CommentAccessControl
|
||||
import fr.dcproject.component.comment.generic.CommentRef
|
||||
import fr.dcproject.component.comment.generic.CommentRepository
|
||||
import io.ktor.application.call
|
||||
import io.ktor.features.NotFoundException
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object GetOneComment {
|
||||
@Location("/comments/{comment}")
|
||||
class CommentRequest(comment: UUID) {
|
||||
val comment = CommentRef(comment)
|
||||
}
|
||||
|
||||
fun Route.getOneComment(repo: CommentRepository, ac: CommentAccessControl) {
|
||||
get<CommentRequest> {
|
||||
val comment = repo.findById(it.comment.id) ?: throw NotFoundException("Comment ${it.comment.id} not found")
|
||||
ac.assert { canView(comment, citizenOrNull) }
|
||||
|
||||
call.respond(HttpStatusCode.OK, comment)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package fr.dcproject.component.comment.generic.routes
|
||||
|
||||
import fr.dcproject.component.comment.generic.routes.CreateCommentChildren.createCommentChildren
|
||||
import fr.dcproject.component.comment.generic.routes.EditComment.editComment
|
||||
import fr.dcproject.component.comment.generic.routes.GetCommentChildren.getChildrenComments
|
||||
import fr.dcproject.component.comment.generic.routes.GetOneComment.getOneComment
|
||||
import io.ktor.auth.authenticate
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.routing.Routing
|
||||
import org.koin.ktor.ext.get
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
fun Routing.installCommentRoutes() {
|
||||
authenticate(optional = true) {
|
||||
editComment(get(), get())
|
||||
getOneComment(get(), get())
|
||||
createCommentChildren(get(), get())
|
||||
getChildrenComments(get(), get())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package fr.dcproject.component.constitution
|
||||
|
||||
import fr.dcproject.common.entity.TargetI
|
||||
import fr.dcproject.common.entity.TargetRef
|
||||
import fr.dcproject.component.article.ArticleForListing
|
||||
import fr.dcproject.component.article.ArticleI
|
||||
import fr.dcproject.component.citizen.CitizenSimple
|
||||
import fr.dcproject.component.citizen.CitizenWithUserI
|
||||
import fr.dcproject.component.constitution.ConstitutionSimple.TitleSimple
|
||||
import fr.postgresjson.entity.EntityCreatedAt
|
||||
import fr.postgresjson.entity.EntityCreatedAtImp
|
||||
import fr.postgresjson.entity.EntityCreatedBy
|
||||
import fr.postgresjson.entity.EntityCreatedByImp
|
||||
import fr.postgresjson.entity.EntityDeletedAt
|
||||
import fr.postgresjson.entity.EntityDeletedAtImp
|
||||
import fr.postgresjson.entity.EntityVersioning
|
||||
import fr.postgresjson.entity.UuidEntity
|
||||
import fr.postgresjson.entity.UuidEntityVersioning
|
||||
import java.util.UUID
|
||||
|
||||
class Constitution(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
title: String,
|
||||
anonymous: Boolean = true,
|
||||
titles: MutableList<TitleSimple<ArticleForListing>> = mutableListOf(),
|
||||
draft: Boolean = false,
|
||||
lastVersion: Boolean = false,
|
||||
override val createdBy: CitizenSimple
|
||||
) : ConstitutionSimple<CitizenSimple, TitleSimple<ArticleForListing>>(
|
||||
id,
|
||||
title = title,
|
||||
anonymous = anonymous,
|
||||
titles = titles,
|
||||
draft = draft,
|
||||
lastVersion = lastVersion,
|
||||
createdBy = createdBy
|
||||
) {
|
||||
|
||||
class Title(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
name: String,
|
||||
rank: Int? = null,
|
||||
override val articles: MutableList<ArticleForListing> = mutableListOf()
|
||||
) : TitleSimple<ArticleForListing>(id, name, rank)
|
||||
}
|
||||
|
||||
open class ConstitutionSimple<Cr : CitizenWithUserI, T : TitleSimple<*>>(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
val title: String,
|
||||
val anonymous: Boolean = true,
|
||||
val titles: MutableList<T> = mutableListOf(),
|
||||
val draft: Boolean = false,
|
||||
val lastVersion: Boolean = false,
|
||||
override val createdBy: Cr,
|
||||
versionId: UUID = UUID.randomUUID()
|
||||
) : ConstitutionRef(id),
|
||||
EntityVersioning<UUID, Int> by UuidEntityVersioning(versionId = versionId, versionNumber = 0),
|
||||
EntityCreatedAt by EntityCreatedAtImp(),
|
||||
EntityCreatedBy<Cr> by EntityCreatedByImp(createdBy),
|
||||
EntityDeletedAt by EntityDeletedAtImp() {
|
||||
|
||||
init {
|
||||
titles.forEachIndexed { index, title ->
|
||||
title.rank = index
|
||||
}
|
||||
}
|
||||
|
||||
open class TitleSimple<A : ArticleI>(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
var name: String,
|
||||
var rank: Int? = null,
|
||||
open val articles: MutableList<A> = mutableListOf()
|
||||
) : TitleRef(id)
|
||||
}
|
||||
|
||||
open class ConstitutionRef(id: UUID = UUID.randomUUID()) : ConstitutionS(id) {
|
||||
open class TitleRef(
|
||||
id: UUID = UUID.randomUUID()
|
||||
) : UuidEntity(id)
|
||||
}
|
||||
|
||||
sealed class ConstitutionS(id: UUID = UUID.randomUUID()) : TargetRef(id), TargetI
|
||||
@@ -0,0 +1,34 @@
|
||||
package fr.dcproject.component.constitution
|
||||
|
||||
import fr.dcproject.common.security.AccessControl
|
||||
import fr.dcproject.common.security.AccessResponse
|
||||
import fr.dcproject.component.citizen.CitizenI
|
||||
import fr.postgresjson.entity.EntityCreatedBy
|
||||
import fr.postgresjson.entity.EntityDeletedAt
|
||||
|
||||
class ConstitutionAccessControl : AccessControl() {
|
||||
fun canCreate(subject: ConstitutionS, citizen: CitizenI?): AccessResponse = when {
|
||||
citizen == null -> denied("You must be connected to create constitution", "constitution.create.notConnected")
|
||||
else -> granted()
|
||||
}
|
||||
|
||||
fun <S : ConstitutionSimple<*, *>> canView(subjects: List<S>, citizen: CitizenI?): AccessResponse =
|
||||
canAll(subjects) { canView(it, citizen) }
|
||||
|
||||
fun <S> canView(subject: S, citizen: CitizenI?): AccessResponse where S : EntityDeletedAt, S : ConstitutionS = when {
|
||||
subject.isDeleted() -> denied("You cannot view a deleted constitution", "constitution.view.deleted")
|
||||
else -> granted()
|
||||
}
|
||||
|
||||
fun <S> canDelete(subject: S, citizen: CitizenI?): AccessResponse where S : EntityCreatedBy<CitizenI>, S : ConstitutionRef = when {
|
||||
citizen == null -> denied("You must be connected to delete constitution", "constitution.delete.notConnected")
|
||||
subject.createdBy.id != citizen.id -> denied("You cannot delete the constitution of other citizen", "constitution.delete.otherCitizen")
|
||||
else -> granted()
|
||||
}
|
||||
|
||||
fun <S> canUpdate(subject: S, citizen: CitizenI?): AccessResponse where S : EntityCreatedBy<CitizenI>, S : ConstitutionRef = when {
|
||||
citizen == null -> denied("You must be connected to update constitution", "constitution.update.notConnected")
|
||||
subject.createdBy.id != citizen.id -> denied("You cannot update the constitution of other citizen", "constitution.update.otherCitizen")
|
||||
else -> granted()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package fr.dcproject.component.constitution
|
||||
|
||||
import fr.dcproject.component.article.ArticleRef
|
||||
import fr.dcproject.component.citizen.CitizenWithUserI
|
||||
import fr.dcproject.component.constitution.ConstitutionSimple.TitleSimple
|
||||
import fr.postgresjson.connexion.Paginated
|
||||
import fr.postgresjson.connexion.Requester
|
||||
import fr.postgresjson.repository.RepositoryI
|
||||
import fr.postgresjson.repository.RepositoryI.Direction
|
||||
import net.pearx.kasechange.toSnakeCase
|
||||
import java.util.UUID
|
||||
import fr.dcproject.component.constitution.Constitution as ConstitutionEntity
|
||||
|
||||
class ConstitutionRepository(override var requester: Requester) : RepositoryI {
|
||||
fun findById(id: UUID): ConstitutionEntity? {
|
||||
val function = requester.getFunction("find_constitution_by_id")
|
||||
return function.selectOne("id" to id)
|
||||
}
|
||||
|
||||
fun find(
|
||||
page: Int = 1,
|
||||
limit: Int = 50,
|
||||
sort: String? = null,
|
||||
direction: Direction? = null,
|
||||
search: String? = null
|
||||
): Paginated<ConstitutionEntity> {
|
||||
return requester
|
||||
.getFunction("find_constitutions")
|
||||
.select(
|
||||
page,
|
||||
limit,
|
||||
"sort" to sort?.toSnakeCase(),
|
||||
"direction" to direction,
|
||||
"search" to search
|
||||
)
|
||||
}
|
||||
|
||||
fun upsert(constitution: ConstitutionSimple<CitizenWithUserI, TitleSimple<ArticleRef>>): ConstitutionEntity? {
|
||||
return requester
|
||||
.getFunction("upsert_constitution")
|
||||
.selectOne("resource" to constitution)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package fr.dcproject.component.constitution
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val constitutionKoinModule = module {
|
||||
single { ConstitutionRepository(get()) }
|
||||
single { ConstitutionAccessControl() }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package fr.dcproject.component.constitution.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.common.utils.receiveOrBadRequest
|
||||
import fr.dcproject.component.article.ArticleRef
|
||||
import fr.dcproject.component.auth.citizen
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.citizen.Citizen
|
||||
import fr.dcproject.component.citizen.CitizenWithUserI
|
||||
import fr.dcproject.component.constitution.ConstitutionAccessControl
|
||||
import fr.dcproject.component.constitution.ConstitutionRepository
|
||||
import fr.dcproject.component.constitution.ConstitutionSimple
|
||||
import fr.dcproject.component.constitution.ConstitutionSimple.TitleSimple
|
||||
import fr.dcproject.component.constitution.routes.CreateConstitution.PostConstitutionRequest.Input
|
||||
import fr.dcproject.component.constitution.routes.CreateConstitution.PostConstitutionRequest.Input.Title
|
||||
import fr.postgresjson.entity.UuidEntity
|
||||
import io.ktor.application.call
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.post
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object CreateConstitution {
|
||||
@Location("/constitutions")
|
||||
class PostConstitutionRequest {
|
||||
class Input(
|
||||
var title: String,
|
||||
var anonymous: Boolean = true,
|
||||
var titles: MutableList<Title> = mutableListOf(),
|
||||
var draft: Boolean = false,
|
||||
var lastVersion: Boolean = false,
|
||||
var versionId: UUID = UUID.randomUUID()
|
||||
) {
|
||||
init {
|
||||
titles.forEachIndexed { index, title ->
|
||||
title.rank = index
|
||||
}
|
||||
}
|
||||
|
||||
class Title(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
var name: String,
|
||||
var rank: Int? = null,
|
||||
var articles: MutableList<ArticleRef> = mutableListOf()
|
||||
) : UuidEntity(id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNewConstitution(input: Input, citizen: Citizen) = input.run {
|
||||
ConstitutionSimple<CitizenWithUserI, TitleSimple<ArticleRef>>(
|
||||
id = UUID.randomUUID(),
|
||||
title = title,
|
||||
titles = titles.create(),
|
||||
createdBy = citizen,
|
||||
versionId = versionId
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<Title>.create(): MutableList<TitleSimple<ArticleRef>> =
|
||||
map { it.create() }.toMutableList()
|
||||
|
||||
private fun Title.create(): TitleSimple<ArticleRef> =
|
||||
TitleSimple(
|
||||
id,
|
||||
name,
|
||||
rank,
|
||||
articles
|
||||
)
|
||||
|
||||
fun Route.createConstitution(repo: ConstitutionRepository, ac: ConstitutionAccessControl) {
|
||||
post<PostConstitutionRequest> {
|
||||
getNewConstitution(call.receiveOrBadRequest(), citizen).let {
|
||||
ac.assert { canCreate(it, citizenOrNull) }
|
||||
repo.upsert(it)
|
||||
call.respond(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package fr.dcproject.component.constitution.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.constitution.ConstitutionAccessControl
|
||||
import fr.dcproject.component.constitution.ConstitutionRepository
|
||||
import fr.dcproject.routes.PaginatedRequest
|
||||
import fr.dcproject.routes.PaginatedRequestI
|
||||
import fr.postgresjson.repository.RepositoryI
|
||||
import io.ktor.application.call
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object FindConstitutions {
|
||||
@Location("/constitutions")
|
||||
class FindConstitutionsRequest(
|
||||
page: Int = 1,
|
||||
limit: Int = 50,
|
||||
val sort: String? = null,
|
||||
val direction: RepositoryI.Direction? = null,
|
||||
val search: String? = null
|
||||
) : PaginatedRequestI by PaginatedRequest(page, limit)
|
||||
|
||||
fun Route.findConstitutions(repo: ConstitutionRepository, ac: ConstitutionAccessControl) {
|
||||
get<FindConstitutionsRequest> {
|
||||
val constitutions = repo.find(it.page, it.limit, it.sort, it.direction, it.search)
|
||||
ac.assert { canView(constitutions.result, citizenOrNull) }
|
||||
call.respond(constitutions)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package fr.dcproject.component.constitution.routes
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.constitution.ConstitutionAccessControl
|
||||
import fr.dcproject.component.constitution.ConstitutionRef
|
||||
import fr.dcproject.component.constitution.ConstitutionRepository
|
||||
import io.ktor.application.call
|
||||
import io.ktor.features.NotFoundException
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object GetConstitution {
|
||||
@Location("/constitutions/{constitution}")
|
||||
class GetConstitutionRequest(constitution: UUID) {
|
||||
val constitution = ConstitutionRef(constitution)
|
||||
}
|
||||
|
||||
fun Route.getConstitution(ac: ConstitutionAccessControl, constitutionRepo: ConstitutionRepository) {
|
||||
get<GetConstitutionRequest> {
|
||||
val constitution = constitutionRepo.findById(it.constitution.id) ?: throw NotFoundException("Unable to find constitution ${it.constitution.id}")
|
||||
ac.assert { canView(constitution, citizenOrNull) }
|
||||
call.respond(constitution)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package fr.dcproject.component.constitution.routes
|
||||
|
||||
import fr.dcproject.component.constitution.routes.CreateConstitution.createConstitution
|
||||
import fr.dcproject.component.constitution.routes.FindConstitutions.findConstitutions
|
||||
import fr.dcproject.component.constitution.routes.GetConstitution.getConstitution
|
||||
import io.ktor.auth.authenticate
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.routing.Routing
|
||||
import org.koin.ktor.ext.get
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
fun Routing.installConstitutionRoutes() {
|
||||
authenticate(optional = true) {
|
||||
getConstitution(get(), get())
|
||||
findConstitutions(get(), get())
|
||||
createConstitution(get(), get())
|
||||
}
|
||||
}
|
||||
18
src/main/kotlin/fr/dcproject/component/doc/routes/OpenAPI.kt
Normal file
18
src/main/kotlin/fr/dcproject/component/doc/routes/OpenAPI.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
package fr.dcproject.component.doc.routes
|
||||
|
||||
import fr.dcproject.common.utils.readResource
|
||||
import io.ktor.application.call
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.response.respondText
|
||||
import io.ktor.routing.Route
|
||||
import io.ktor.routing.get
|
||||
import io.ktor.util.KtorExperimentalAPI
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
@KtorExperimentalAPI
|
||||
fun Route.definition() {
|
||||
get("/") {
|
||||
call.respondText("/openapi.yaml".readResource(), ContentType("text", "yaml"))
|
||||
}
|
||||
}
|
||||
16
src/main/kotlin/fr/dcproject/component/doc/routes/install.kt
Normal file
16
src/main/kotlin/fr/dcproject/component/doc/routes/install.kt
Normal file
@@ -0,0 +1,16 @@
|
||||
package fr.dcproject.component.doc.routes
|
||||
|
||||
import io.ktor.auth.authenticate
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.routing.Routing
|
||||
import io.ktor.util.KtorExperimentalAPI
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
||||
@KtorExperimentalAPI
|
||||
@ExperimentalCoroutinesApi
|
||||
@KtorExperimentalLocationsAPI
|
||||
fun Routing.installDocRoutes() {
|
||||
authenticate(optional = true) {
|
||||
definition()
|
||||
}
|
||||
}
|
||||
46
src/main/kotlin/fr/dcproject/component/follow/Follow.kt
Normal file
46
src/main/kotlin/fr/dcproject/component/follow/Follow.kt
Normal file
@@ -0,0 +1,46 @@
|
||||
package fr.dcproject.component.follow
|
||||
|
||||
import fr.dcproject.common.entity.ExtraI
|
||||
import fr.dcproject.common.entity.HasTarget
|
||||
import fr.dcproject.common.entity.TargetI
|
||||
import fr.dcproject.component.citizen.CitizenBasic
|
||||
import fr.dcproject.component.citizen.CitizenBasicI
|
||||
import fr.dcproject.component.citizen.CitizenI
|
||||
import fr.postgresjson.entity.EntityCreatedAt
|
||||
import fr.postgresjson.entity.EntityCreatedAtImp
|
||||
import fr.postgresjson.entity.EntityCreatedBy
|
||||
import fr.postgresjson.entity.EntityCreatedByImp
|
||||
import fr.postgresjson.entity.UuidEntityI
|
||||
import java.util.UUID
|
||||
|
||||
@Deprecated("")
|
||||
class Follow<T : TargetI>(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
override val createdBy: CitizenBasic,
|
||||
override var target: T
|
||||
) : ExtraI<T, CitizenBasicI>,
|
||||
FollowSimple<T, CitizenBasicI>(id, createdBy, target)
|
||||
|
||||
@Deprecated("")
|
||||
open class FollowSimple<T : TargetI, C : CitizenI>(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
override val createdBy: C,
|
||||
override var target: T
|
||||
) : ExtraI<T, C>,
|
||||
FollowRef(id),
|
||||
EntityCreatedAt by EntityCreatedAtImp(),
|
||||
EntityCreatedBy<C> by EntityCreatedByImp(createdBy)
|
||||
|
||||
class FollowForUpdate<T : TargetI, C : CitizenI>(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
override val target: T,
|
||||
override val createdBy: C
|
||||
) : FollowRef(id),
|
||||
HasTarget<T>,
|
||||
EntityCreatedBy<C> by EntityCreatedByImp<C>(createdBy)
|
||||
|
||||
open class FollowRef(
|
||||
override val id: UUID
|
||||
) : FollowI
|
||||
|
||||
interface FollowI : UuidEntityI
|
||||
@@ -0,0 +1,26 @@
|
||||
package fr.dcproject.component.follow
|
||||
|
||||
import fr.dcproject.common.security.AccessControl
|
||||
import fr.dcproject.common.security.AccessResponse
|
||||
import fr.dcproject.component.citizen.CitizenI
|
||||
import fr.dcproject.component.follow.Follow as FollowEntity
|
||||
|
||||
class FollowAccessControl : AccessControl() {
|
||||
fun canCreate(subject: FollowI, citizen: CitizenI?): AccessResponse {
|
||||
return if (citizen == null) denied("You must be connected to follow", "follow.create.notConnected")
|
||||
else granted()
|
||||
}
|
||||
|
||||
fun canDelete(subject: FollowI, citizen: CitizenI?): AccessResponse {
|
||||
return if (citizen == null) denied("You must be connected to unfollow", "follow.delete.notConnected")
|
||||
else granted()
|
||||
}
|
||||
|
||||
fun <S : FollowEntity<*>> canView(subjects: List<S>, citizen: CitizenI?): AccessResponse =
|
||||
canAll(subjects) { canView(it, citizen) }
|
||||
|
||||
fun canView(subject: FollowEntity<*>, citizen: CitizenI?): AccessResponse {
|
||||
return if ((citizen != null && subject.createdBy.id == citizen.id) || !subject.createdBy.followAnonymous) granted()
|
||||
else denied("You cannot view an anonymous follow", "follow.view.anonymous")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package fr.dcproject.component.follow
|
||||
|
||||
import fr.dcproject.common.entity.TargetRef
|
||||
import fr.dcproject.component.article.ArticleForView
|
||||
import fr.dcproject.component.article.ArticleRef
|
||||
import fr.dcproject.component.citizen.CitizenI
|
||||
import fr.dcproject.component.citizen.CitizenRef
|
||||
import fr.dcproject.component.constitution.ConstitutionRef
|
||||
import fr.postgresjson.connexion.Paginated
|
||||
import fr.postgresjson.connexion.Requester
|
||||
import fr.postgresjson.entity.UuidEntity
|
||||
import fr.postgresjson.repository.RepositoryI
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import java.util.UUID
|
||||
import fr.dcproject.component.constitution.Constitution as ConstitutionEntity
|
||||
import fr.dcproject.component.follow.Follow as FollowEntity
|
||||
|
||||
sealed class FollowRepository<IN : TargetRef, OUT : TargetRef>(override var requester: Requester) : RepositoryI {
|
||||
open fun findByCitizen(
|
||||
citizen: CitizenI,
|
||||
page: Int = 1,
|
||||
limit: Int = 50
|
||||
): Paginated<FollowEntity<OUT>> =
|
||||
findByCitizen(citizen.id, page, limit)
|
||||
|
||||
open fun findByCitizen(
|
||||
citizenId: UUID,
|
||||
page: Int = 1,
|
||||
limit: Int = 50
|
||||
): Paginated<FollowEntity<OUT>> {
|
||||
return requester
|
||||
.getFunction("find_follows_by_citizen")
|
||||
.select(
|
||||
page,
|
||||
limit,
|
||||
"created_by_id" to citizenId
|
||||
)
|
||||
}
|
||||
|
||||
fun follow(follow: FollowForUpdate<IN, *>) {
|
||||
requester
|
||||
.getFunction("follow")
|
||||
.sendQuery(
|
||||
"reference" to follow.target.reference,
|
||||
"target_id" to follow.target.id,
|
||||
"created_by_id" to follow.createdBy.id
|
||||
)
|
||||
}
|
||||
|
||||
fun unfollow(follow: FollowForUpdate<IN, *>) {
|
||||
requester
|
||||
.getFunction("unfollow")
|
||||
.sendQuery(
|
||||
"reference" to follow.target.reference,
|
||||
"target_id" to follow.target.id,
|
||||
"created_by_id" to follow.createdBy.id
|
||||
)
|
||||
}
|
||||
|
||||
open fun findFollow(
|
||||
citizen: CitizenI,
|
||||
target: TargetRef
|
||||
): FollowEntity<OUT>? =
|
||||
requester
|
||||
.getFunction("find_follow")
|
||||
.selectOne(
|
||||
"citizen_id" to citizen.id,
|
||||
"target_id" to target.id,
|
||||
"target_reference" to target.reference
|
||||
)
|
||||
|
||||
fun findFollowsByTarget(
|
||||
target: UuidEntity,
|
||||
bulkSize: Int = 300
|
||||
): Flow<FollowSimple<IN, CitizenRef>> = flow {
|
||||
var nextPage = 1
|
||||
do {
|
||||
val paginate = findFollowsByTarget(target, nextPage, bulkSize)
|
||||
paginate.result.forEach {
|
||||
emit(it)
|
||||
}
|
||||
nextPage = paginate.currentPage + 1
|
||||
} while (!paginate.isLastPage())
|
||||
}
|
||||
|
||||
abstract fun findFollowsByTarget(
|
||||
target: UuidEntity,
|
||||
page: Int = 1,
|
||||
limit: Int = 300
|
||||
): Paginated<FollowSimple<IN, CitizenRef>>
|
||||
}
|
||||
|
||||
class FollowArticleRepository(requester: Requester) : FollowRepository<ArticleRef, ArticleForView>(requester) {
|
||||
override fun findByCitizen(
|
||||
citizenId: UUID,
|
||||
page: Int,
|
||||
limit: Int
|
||||
): Paginated<FollowEntity<ArticleForView>> {
|
||||
return requester.run {
|
||||
getFunction("find_follows_article_by_citizen")
|
||||
.select(
|
||||
page,
|
||||
limit,
|
||||
"created_by_id" to citizenId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun findFollowsByTarget(
|
||||
target: UuidEntity,
|
||||
page: Int,
|
||||
limit: Int
|
||||
): Paginated<FollowSimple<ArticleRef, CitizenRef>> {
|
||||
return requester
|
||||
.getFunction("find_follows_article_by_target")
|
||||
.select(
|
||||
page,
|
||||
limit,
|
||||
"target_id" to target.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class FollowConstitutionRepository(requester: Requester) : FollowRepository<ConstitutionRef, ConstitutionEntity>(requester) {
|
||||
override fun findByCitizen(
|
||||
citizenId: UUID,
|
||||
page: Int,
|
||||
limit: Int
|
||||
): Paginated<FollowEntity<ConstitutionEntity>> {
|
||||
return requester.run {
|
||||
getFunction("find_follows_constitution_by_citizen")
|
||||
.select(
|
||||
page,
|
||||
limit,
|
||||
"created_by_id" to citizenId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun findFollowsByTarget(
|
||||
target: UuidEntity,
|
||||
page: Int,
|
||||
limit: Int
|
||||
): Paginated<FollowSimple<ConstitutionRef, CitizenRef>> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package fr.dcproject.component.follow
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val followKoinModule = module {
|
||||
single { FollowArticleRepository(get()) }
|
||||
single { FollowConstitutionRepository(get()) }
|
||||
single { FollowAccessControl() }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package fr.dcproject.component.follow.routes.article
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.article.ArticleRef
|
||||
import fr.dcproject.component.auth.citizen
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.follow.FollowAccessControl
|
||||
import fr.dcproject.component.follow.FollowArticleRepository
|
||||
import fr.dcproject.component.follow.FollowForUpdate
|
||||
import io.ktor.application.call
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.post
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object FollowArticle {
|
||||
@Location("/articles/{article}/follows")
|
||||
class ArticleFollowRequest(article: UUID) {
|
||||
val article = ArticleRef(article)
|
||||
}
|
||||
|
||||
fun Route.followArticle(repo: FollowArticleRepository, ac: FollowAccessControl) {
|
||||
post<ArticleFollowRequest> {
|
||||
val follow = FollowForUpdate(target = it.article, createdBy = this.citizen)
|
||||
ac.assert { canCreate(follow, citizenOrNull) }
|
||||
repo.follow(follow)
|
||||
call.respond(HttpStatusCode.Created)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package fr.dcproject.component.follow.routes.article
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.article.ArticleRef
|
||||
import fr.dcproject.component.auth.citizen
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.follow.FollowAccessControl
|
||||
import fr.dcproject.component.follow.FollowArticleRepository
|
||||
import io.ktor.application.call
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object GetFollowArticle {
|
||||
@Location("/articles/{article}/follows")
|
||||
class ArticleFollowRequest(article: UUID) {
|
||||
val article = ArticleRef(article)
|
||||
}
|
||||
|
||||
fun Route.getFollowArticle(repo: FollowArticleRepository, ac: FollowAccessControl) {
|
||||
get<ArticleFollowRequest> {
|
||||
repo.findFollow(citizen, it.article)?.let { follow ->
|
||||
ac.assert { canView(follow, citizenOrNull) }
|
||||
call.respond(follow)
|
||||
} ?: call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package fr.dcproject.component.follow.routes.article
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.citizen.CitizenRef
|
||||
import fr.dcproject.component.follow.FollowAccessControl
|
||||
import fr.dcproject.component.follow.FollowArticleRepository
|
||||
import io.ktor.application.call
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object GetMyFollowsArticle {
|
||||
@Location("/citizens/{citizen}/follows/articles")
|
||||
class CitizenFollowArticleRequest(citizen: UUID) {
|
||||
val citizen = CitizenRef(citizen)
|
||||
}
|
||||
|
||||
fun Route.getMyFollowsArticle(repo: FollowArticleRepository, ac: FollowAccessControl) {
|
||||
get<CitizenFollowArticleRequest> {
|
||||
val follows = repo.findByCitizen(it.citizen)
|
||||
ac.assert { canView(follows.result, citizenOrNull) }
|
||||
call.respond(follows)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package fr.dcproject.component.follow.routes.article
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.article.ArticleRef
|
||||
import fr.dcproject.component.auth.citizen
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.follow.FollowAccessControl
|
||||
import fr.dcproject.component.follow.FollowArticleRepository
|
||||
import fr.dcproject.component.follow.FollowForUpdate
|
||||
import io.ktor.application.call
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.delete
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object UnfollowArticle {
|
||||
@Location("/articles/{article}/follows")
|
||||
class ArticleFollowRequest(article: UUID) {
|
||||
val article = ArticleRef(article)
|
||||
}
|
||||
|
||||
fun Route.unfollowArticle(repo: FollowArticleRepository, ac: FollowAccessControl) {
|
||||
delete<ArticleFollowRequest> {
|
||||
val follow = FollowForUpdate(target = it.article, createdBy = this.citizen)
|
||||
ac.assert { canDelete(follow, citizenOrNull) }
|
||||
repo.unfollow(follow)
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package fr.dcproject.component.follow.routes.article
|
||||
|
||||
import fr.dcproject.component.follow.routes.article.FollowArticle.followArticle
|
||||
import fr.dcproject.component.follow.routes.article.GetFollowArticle.getFollowArticle
|
||||
import fr.dcproject.component.follow.routes.article.GetMyFollowsArticle.getMyFollowsArticle
|
||||
import fr.dcproject.component.follow.routes.article.UnfollowArticle.unfollowArticle
|
||||
import io.ktor.auth.authenticate
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.routing.Routing
|
||||
import org.koin.ktor.ext.get
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
fun Routing.installFollowArticleRoutes() {
|
||||
authenticate(optional = true) {
|
||||
followArticle(get(), get())
|
||||
unfollowArticle(get(), get())
|
||||
getFollowArticle(get(), get())
|
||||
getMyFollowsArticle(get(), get())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package fr.dcproject.component.follow.routes.constitution
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.auth.citizen
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.constitution.ConstitutionRef
|
||||
import fr.dcproject.component.follow.FollowAccessControl
|
||||
import fr.dcproject.component.follow.FollowConstitutionRepository
|
||||
import fr.dcproject.component.follow.FollowForUpdate
|
||||
import io.ktor.application.call
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.post
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object FollowConstitution {
|
||||
@Location("/constitutions/{constitution}/follows")
|
||||
class ConstitutionFollowRequest(constitution: UUID) {
|
||||
val constitution = ConstitutionRef(constitution)
|
||||
}
|
||||
|
||||
fun Route.followConstitution(repo: FollowConstitutionRepository, ac: FollowAccessControl) {
|
||||
post<ConstitutionFollowRequest> {
|
||||
val follow = FollowForUpdate(target = it.constitution, createdBy = this.citizen)
|
||||
ac.assert { canCreate(follow, citizenOrNull) }
|
||||
repo.follow(follow)
|
||||
call.respond(HttpStatusCode.Created)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package fr.dcproject.component.follow.routes.constitution
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.auth.citizen
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.constitution.ConstitutionRef
|
||||
import fr.dcproject.component.follow.FollowAccessControl
|
||||
import fr.dcproject.component.follow.FollowConstitutionRepository
|
||||
import io.ktor.application.call
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object GetFollowConstitution {
|
||||
@Location("/constitutions/{constitution}/follows")
|
||||
class ConstitutionFollowRequest(constitution: UUID) {
|
||||
val constitution = ConstitutionRef(constitution)
|
||||
}
|
||||
|
||||
fun Route.getFollowConstitution(repo: FollowConstitutionRepository, ac: FollowAccessControl) {
|
||||
get<ConstitutionFollowRequest> {
|
||||
repo.findFollow(citizen, it.constitution)?.let { follow ->
|
||||
ac.assert { canView(follow, citizenOrNull) }
|
||||
call.respond(follow)
|
||||
} ?: call.respond(HttpStatusCode.NotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package fr.dcproject.component.follow.routes.constitution
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.citizen.CitizenRef
|
||||
import fr.dcproject.component.follow.FollowAccessControl
|
||||
import fr.dcproject.component.follow.FollowConstitutionRepository
|
||||
import io.ktor.application.call
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.get
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object GetMyFollowsConstitution {
|
||||
@Location("/citizens/{citizen}/follows/constitutions")
|
||||
class CitizenFollowConstitutionRequest(citizen: UUID) {
|
||||
val citizen = CitizenRef(citizen)
|
||||
}
|
||||
|
||||
fun Route.getMyFollowsConstitution(repo: FollowConstitutionRepository, ac: FollowAccessControl) {
|
||||
get<CitizenFollowConstitutionRequest> {
|
||||
val follows = repo.findByCitizen(it.citizen)
|
||||
ac.assert { canView(follows.result, citizenOrNull) }
|
||||
call.respond(follows)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package fr.dcproject.component.follow.routes.constitution
|
||||
|
||||
import fr.dcproject.common.security.assert
|
||||
import fr.dcproject.component.auth.citizen
|
||||
import fr.dcproject.component.auth.citizenOrNull
|
||||
import fr.dcproject.component.constitution.ConstitutionRef
|
||||
import fr.dcproject.component.follow.FollowAccessControl
|
||||
import fr.dcproject.component.follow.FollowConstitutionRepository
|
||||
import fr.dcproject.component.follow.FollowForUpdate
|
||||
import io.ktor.application.call
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.locations.Location
|
||||
import io.ktor.locations.delete
|
||||
import io.ktor.response.respond
|
||||
import io.ktor.routing.Route
|
||||
import java.util.UUID
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
object UnfollowConstitution {
|
||||
@Location("/constitutions/{constitution}/follows")
|
||||
class ConstitutionUnfollowRequest(constitution: UUID) {
|
||||
val constitution = ConstitutionRef(constitution)
|
||||
}
|
||||
|
||||
fun Route.unfollowConstitution(repo: FollowConstitutionRepository, ac: FollowAccessControl) {
|
||||
delete<ConstitutionUnfollowRequest> {
|
||||
val follow = FollowForUpdate(target = it.constitution, createdBy = this.citizen)
|
||||
ac.assert { canDelete(follow, citizenOrNull) }
|
||||
repo.unfollow(follow)
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package fr.dcproject.component.follow.routes.constitution
|
||||
|
||||
import fr.dcproject.component.follow.routes.constitution.FollowConstitution.followConstitution
|
||||
import fr.dcproject.component.follow.routes.constitution.GetFollowConstitution.getFollowConstitution
|
||||
import fr.dcproject.component.follow.routes.constitution.GetMyFollowsConstitution.getMyFollowsConstitution
|
||||
import fr.dcproject.component.follow.routes.constitution.UnfollowConstitution.unfollowConstitution
|
||||
import io.ktor.auth.authenticate
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.routing.Routing
|
||||
import org.koin.ktor.ext.get
|
||||
|
||||
@KtorExperimentalLocationsAPI
|
||||
fun Routing.installFollowConstitutionRoutes() {
|
||||
authenticate(optional = true) {
|
||||
followConstitution(get(), get())
|
||||
unfollowConstitution(get(), get())
|
||||
getFollowConstitution(get(), get())
|
||||
getMyFollowsConstitution(get(), get())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package fr.dcproject.component.notification
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import com.fasterxml.jackson.datatype.joda.JodaModule
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import fr.dcproject.component.article.ArticleForView
|
||||
import fr.postgresjson.entity.UuidEntity
|
||||
import org.joda.time.DateTime
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
open class Notification(
|
||||
val type: String,
|
||||
val createdAt: DateTime = DateTime.now()
|
||||
) {
|
||||
val id: Double = nextId()
|
||||
|
||||
private fun nextId(): Double {
|
||||
return (createdAt.millis.toString() + nextInt().toString()).toDouble()
|
||||
}
|
||||
|
||||
override fun toString(): String = mapper.writeValueAsString(this) ?: error("Unable to serialize notification")
|
||||
|
||||
fun toByteArray() = toString().toByteArray()
|
||||
|
||||
companion object {
|
||||
private val counter: AtomicInteger = AtomicInteger(1000)
|
||||
fun nextInt(): Int {
|
||||
counter.compareAndSet(9999, 1000)
|
||||
return counter.incrementAndGet()
|
||||
}
|
||||
|
||||
val mapper = jacksonObjectMapper().apply {
|
||||
registerModule(SimpleModule())
|
||||
propertyNamingStrategy = PropertyNamingStrategies.SNAKE_CASE
|
||||
|
||||
registerModule(JodaModule())
|
||||
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
}
|
||||
|
||||
inline fun <reified T : Notification> fromString(raw: String): T = mapper.readValue(raw)
|
||||
}
|
||||
}
|
||||
|
||||
open class EntityNotification(
|
||||
val target: UuidEntity,
|
||||
type: String,
|
||||
val action: String
|
||||
) : Notification(type)
|
||||
|
||||
class ArticleUpdateNotification(
|
||||
target: ArticleForView
|
||||
) : EntityNotification(target, "article", "update")
|
||||
@@ -0,0 +1,113 @@
|
||||
package fr.dcproject.component.notification
|
||||
|
||||
import com.rabbitmq.client.AMQP.BasicProperties
|
||||
import com.rabbitmq.client.BuiltinExchangeType.DIRECT
|
||||
import com.rabbitmq.client.ConnectionFactory
|
||||
import com.rabbitmq.client.Consumer
|
||||
import com.rabbitmq.client.DefaultConsumer
|
||||
import com.rabbitmq.client.Envelope
|
||||
import fr.dcproject.common.entity.TargetRef
|
||||
import fr.dcproject.component.citizen.CitizenRef
|
||||
import fr.dcproject.component.follow.FollowArticleRepository
|
||||
import fr.dcproject.component.follow.FollowConstitutionRepository
|
||||
import fr.dcproject.component.follow.FollowSimple
|
||||
import io.ktor.utils.io.errors.IOException
|
||||
import io.lettuce.core.RedisClient
|
||||
import io.lettuce.core.api.async.RedisAsyncCommands
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
class NotificationConsumer(
|
||||
private val rabbitFactory: ConnectionFactory,
|
||||
private val redisClient: RedisClient,
|
||||
private val followConstitutionRepo: FollowConstitutionRepository,
|
||||
private val followArticleRepo: FollowArticleRepository,
|
||||
private val notificationEmailSender: NotificationEmailSender,
|
||||
private val exchangeName: String,
|
||||
) {
|
||||
private val redisConnection = redisClient.connect() ?: error("Unable to connect to redis")
|
||||
private val redis: RedisAsyncCommands<String, String> = redisConnection.async() ?: error("Unable to connect to redis")
|
||||
private val rabbitConnection = rabbitFactory.newConnection()
|
||||
private val rabbitChannel = rabbitConnection.createChannel()
|
||||
private val logger: Logger = LoggerFactory.getLogger(Publisher::class.qualifiedName)
|
||||
|
||||
fun close() {
|
||||
rabbitChannel.close()
|
||||
rabbitConnection.close()
|
||||
}
|
||||
|
||||
fun start() {
|
||||
/* Config Rabbit */
|
||||
rabbitFactory.newConnection().use { connection ->
|
||||
connection.createChannel().use { channel ->
|
||||
channel.queueDeclare("push", true, false, false, null)
|
||||
channel.queueDeclare("email", true, false, false, null)
|
||||
channel.exchangeDeclare(exchangeName, DIRECT, true)
|
||||
channel.queueBind("push", exchangeName, "")
|
||||
channel.queueBind("email", exchangeName, "")
|
||||
}
|
||||
}
|
||||
|
||||
/* Define Consumer */
|
||||
val consumerPush: Consumer = object : DefaultConsumer(rabbitChannel) {
|
||||
@Throws(IOException::class)
|
||||
override fun handleDelivery(
|
||||
consumerTag: String,
|
||||
envelope: Envelope,
|
||||
properties: BasicProperties,
|
||||
body: ByteArray
|
||||
) = runBlocking {
|
||||
followersFromMessage(body) {
|
||||
redis.zadd(
|
||||
"notification:${it.follow.createdBy.id}",
|
||||
it.event.id,
|
||||
it.rawMessage
|
||||
)
|
||||
}
|
||||
|
||||
rabbitChannel.basicAck(envelope.deliveryTag, false)
|
||||
}
|
||||
}
|
||||
|
||||
val consumerEmail: Consumer = object : DefaultConsumer(rabbitChannel) {
|
||||
@Throws(IOException::class)
|
||||
override fun handleDelivery(
|
||||
consumerTag: String,
|
||||
envelope: Envelope,
|
||||
properties: BasicProperties,
|
||||
body: ByteArray
|
||||
) {
|
||||
runBlocking {
|
||||
followersFromMessage(body) {
|
||||
notificationEmailSender.sendEmail(it.follow)
|
||||
logger.debug("EmailSend to: ${it.follow.createdBy.id}")
|
||||
}
|
||||
}
|
||||
rabbitChannel.basicAck(envelope.deliveryTag, false)
|
||||
}
|
||||
}
|
||||
/* Launch Consumer */
|
||||
rabbitChannel.basicConsume("push", false, consumerPush) // The front consume the redis via Websocket
|
||||
rabbitChannel.basicConsume("email", false, consumerEmail)
|
||||
}
|
||||
|
||||
private suspend fun followersFromMessage(body: ByteArray, action: suspend (DecodedMessage) -> Unit) {
|
||||
val rawMessage: String = body.toString(Charsets.UTF_8)
|
||||
val notification: EntityNotification = Notification.fromString<EntityNotification>(rawMessage) ?: error("Unable to deserialize notification message from rabbit")
|
||||
val follows = when (notification.type) {
|
||||
"article" -> followArticleRepo.findFollowsByTarget(notification.target)
|
||||
"constitution" -> followConstitutionRepo.findFollowsByTarget(notification.target)
|
||||
else -> error("event '${notification.type}' not implemented")
|
||||
}
|
||||
|
||||
follows.collect { action(DecodedMessage(notification, rawMessage, it)) }
|
||||
}
|
||||
|
||||
private class DecodedMessage(
|
||||
val event: EntityNotification,
|
||||
val rawMessage: String,
|
||||
val follow: FollowSimple<out TargetRef, CitizenRef>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package fr.dcproject.component.notification
|
||||
|
||||
import com.sendgrid.helpers.mail.Mail
|
||||
import com.sendgrid.helpers.mail.objects.Content
|
||||
import com.sendgrid.helpers.mail.objects.Email
|
||||
import fr.dcproject.common.email.Mailer
|
||||
import fr.dcproject.common.entity.TargetRef
|
||||
import fr.dcproject.component.article.ArticleRepository
|
||||
import fr.dcproject.component.article.ArticleWithTitleI
|
||||
import fr.dcproject.component.citizen.CitizenBasicI
|
||||
import fr.dcproject.component.citizen.CitizenRef
|
||||
import fr.dcproject.component.citizen.CitizenRepository
|
||||
import fr.dcproject.component.follow.FollowSimple
|
||||
import fr.postgresjson.entity.UuidEntityI
|
||||
import java.util.UUID
|
||||
|
||||
class NotificationEmailSender(
|
||||
private val mailer: Mailer,
|
||||
private val domain: String,
|
||||
private val citizenRepo: CitizenRepository,
|
||||
private val articleRepo: ArticleRepository
|
||||
) {
|
||||
fun sendEmail(follow: FollowSimple<out TargetRef, CitizenRef>) {
|
||||
val citizen = citizenRepo.findById(follow.createdBy.id) ?: noCitizen(follow.createdBy.id)
|
||||
val target = when (follow.target.reference) {
|
||||
"article" ->
|
||||
articleRepo.findById(follow.target.id) ?: noTarget(follow.target.id)
|
||||
else -> noTarget(follow.target.id)
|
||||
}
|
||||
val subject = when (follow.target.reference) {
|
||||
"article" -> """New version for article "${target.title}""""
|
||||
else -> "Notification"
|
||||
}
|
||||
mailer.sendEmail {
|
||||
Mail(
|
||||
Email("notification@$domain"),
|
||||
subject,
|
||||
Email(citizen.email),
|
||||
Content("text/plain", generateContent(citizen, target))
|
||||
).apply {
|
||||
addContent(Content("text/html", generateHtmlContent(citizen, target)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateHtmlContent(citizen: CitizenBasicI, target: UuidEntityI): String? {
|
||||
return when (target) {
|
||||
is ArticleWithTitleI -> """
|
||||
Hello ${citizen.name.getFullName()},<br/>
|
||||
The article "${target.title}" was updated, check it <a href="http://$domain/articles/${target.id}">here</a>
|
||||
""".trimIndent()
|
||||
else -> noTarget(target.id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateContent(citizen: CitizenBasicI, target: UuidEntityI): String {
|
||||
return when (target) {
|
||||
is ArticleWithTitleI -> """
|
||||
Hello ${citizen.name.getFullName()},
|
||||
The article "${target.title}" was updated, check it here: http://$domain/articles/${target.id}
|
||||
""".trimIndent()
|
||||
else -> noTarget(target.id)
|
||||
}
|
||||
}
|
||||
|
||||
class NoCitizen(message: String) : Exception(message)
|
||||
class NoTarget(message: String) : Exception(message)
|
||||
|
||||
private fun noCitizen(id: UUID): Nothing = throw NoCitizen("No Citizen with this id : $id")
|
||||
private fun noTarget(id: UUID): Nothing = throw NoTarget("No Target with this id : $id")
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package fr.dcproject.component.notification
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException
|
||||
import fr.dcproject.component.auth.citizen
|
||||
import fr.dcproject.component.citizen.CitizenI
|
||||
import io.ktor.http.cio.websocket.Frame
|
||||
import io.ktor.http.cio.websocket.Frame.Text
|
||||
import io.ktor.http.cio.websocket.readText
|
||||
import io.ktor.routing.Route
|
||||
import io.ktor.websocket.DefaultWebSocketServerSession
|
||||
import io.lettuce.core.Limit
|
||||
import io.lettuce.core.Range
|
||||
import io.lettuce.core.Range.Boundary
|
||||
import io.lettuce.core.RedisClient
|
||||
import io.lettuce.core.api.async.RedisAsyncCommands
|
||||
import io.lettuce.core.pubsub.RedisPubSubAdapter
|
||||
import io.lettuce.core.pubsub.StatefulRedisPubSubConnection
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
class NotificationsPush private constructor(
|
||||
private val redis: RedisAsyncCommands<String, String>,
|
||||
private val redisConnectionPubSub: StatefulRedisPubSubConnection<String, String>,
|
||||
citizen: CitizenI,
|
||||
incoming: Flow<Notification>,
|
||||
onRecieve: suspend (Notification) -> Unit,
|
||||
) {
|
||||
class Builder(val redisClient: RedisClient) {
|
||||
private val redisConnection = redisClient.connect() ?: error("Unable to connect to redis")
|
||||
private val redisConnectionPubSub = redisClient.connectPubSub() ?: error("Unable to connect to redis")
|
||||
private val redis: RedisAsyncCommands<String, String> = redisConnection.async() ?: error("Unable to connect to redis")
|
||||
|
||||
fun build(
|
||||
citizen: CitizenI,
|
||||
incoming: Flow<Notification>,
|
||||
onRecieve: suspend (Notification) -> Unit,
|
||||
): NotificationsPush = NotificationsPush(redis, redisConnectionPubSub, citizen, incoming, onRecieve)
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
fun build(ws: DefaultWebSocketServerSession): NotificationsPush {
|
||||
/* Convert channel of string from websocket, to a flow of Notification object */
|
||||
val incomingFlow: Flow<Notification> = ws.incoming.consumeAsFlow()
|
||||
.mapNotNull<Frame, Text> { it as? Frame.Text }
|
||||
.map { it.readText() }
|
||||
.map { Notification.fromString(it) }
|
||||
|
||||
return build(ws.call.citizen, incomingFlow) {
|
||||
ws.outgoing.send(Text(it.toString()))
|
||||
}.apply {
|
||||
ws.outgoing.invokeOnClose { close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val key = "notification:${citizen.id}"
|
||||
private var score: Double = 0.0
|
||||
private val listener = object : RedisPubSubAdapter<String, String>() {
|
||||
/* On new key publish */
|
||||
override fun message(pattern: String?, channel: String?, message: String?) {
|
||||
runBlocking {
|
||||
getNotifications().collect {
|
||||
onRecieve(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
/* Mark as read all incoming notifications */
|
||||
GlobalScope.launch {
|
||||
incoming.collect {
|
||||
markAsRead(it)
|
||||
}
|
||||
}
|
||||
|
||||
/* Get old notification and sent it to websocket */
|
||||
runBlocking {
|
||||
getNotifications().collect { onRecieve(it) }
|
||||
}
|
||||
|
||||
/* Lisen redis event, and sent the new notification into websocket */
|
||||
redisConnectionPubSub.run {
|
||||
addListener(listener)
|
||||
|
||||
/* Register to the events */
|
||||
async()?.psubscribe("__key*__:$key") ?: error("Unable to connect to redis")
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
redisConnectionPubSub.removeListener(listener)
|
||||
}
|
||||
|
||||
/* Return flow with all new notifications */
|
||||
private fun getNotifications() = flow<Notification> {
|
||||
redis
|
||||
.zrangebyscoreWithScores(
|
||||
key,
|
||||
Range.from(
|
||||
Boundary.excluding(score),
|
||||
Boundary.including(Double.POSITIVE_INFINITY)
|
||||
),
|
||||
Limit.from(100)
|
||||
)
|
||||
.get().forEach {
|
||||
emit(Notification.fromString(it.value))
|
||||
if (it.score > score) score = it.score
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun markAsRead(notificationMessage: Notification) = coroutineScope {
|
||||
try {
|
||||
redis.zremrangebyscore(
|
||||
key,
|
||||
Range.from(
|
||||
Boundary.including(notificationMessage.id),
|
||||
Boundary.including(notificationMessage.id)
|
||||
)
|
||||
)
|
||||
} catch (e: JsonProcessingException) {
|
||||
LoggerFactory.getLogger(Route::class.qualifiedName)
|
||||
.error("Unable to deserialize notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package fr.dcproject.component.notification
|
||||
|
||||
import com.rabbitmq.client.ConnectionFactory
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
class Publisher(
|
||||
private val factory: ConnectionFactory,
|
||||
private val logger: Logger = LoggerFactory.getLogger(Publisher::class.qualifiedName),
|
||||
private val exchangeName: String,
|
||||
) {
|
||||
suspend fun <T : EntityNotification> publish(it: T): Deferred<Unit> = coroutineScope {
|
||||
async {
|
||||
factory.newConnection().use { connection ->
|
||||
connection.createChannel().use { channel ->
|
||||
channel.basicPublish(exchangeName, "", null, it.toString().toByteArray())
|
||||
logger.debug("Publish message ${it.target.id}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package fr.dcproject.component.notification.routes
|
||||
|
||||
import fr.dcproject.component.notification.NotificationsPush
|
||||
import io.ktor.locations.KtorExperimentalLocationsAPI
|
||||
import io.ktor.routing.Route
|
||||
import io.ktor.websocket.webSocket
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
||||
/**
|
||||
* Consume Websocket, then remove notification in redis.
|
||||
*
|
||||
* Sent all notification to websocket.
|
||||
*/
|
||||
@ExperimentalCoroutinesApi
|
||||
@KtorExperimentalLocationsAPI
|
||||
fun Route.notificationArticle(pushBuilder: NotificationsPush.Builder) {
|
||||
webSocket("/notifications") {
|
||||
pushBuilder.build(this)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user