#15 Count total view

Create Viewable interface
Set Article as Viewable
Create ES client
Now view article increment view count
Create index on start application

add an extention in ApplicationCall, to get citizen or return null
This commit is contained in:
2020-03-11 12:55:39 +01:00
parent f677cac779
commit 90e7e0254d
17 changed files with 325 additions and 13 deletions

View File

@@ -36,6 +36,8 @@ import io.ktor.routing.Routing
import io.ktor.util.KtorExperimentalAPI
import io.ktor.websocket.WebSockets
import org.eclipse.jetty.util.log.Slf4jLog
import org.elasticsearch.client.Request
import org.elasticsearch.client.RestClient
import org.koin.core.qualifier.named
import org.koin.ktor.ext.Koin
import org.koin.ktor.ext.get
@@ -169,6 +171,57 @@ fun Application.module(env: Env = PROD) {
}
}
/* Create index if not exist */
get<RestClient>().run {
if (performRequest(Request("HEAD", "/views?include_type_name=false")).statusLine.statusCode == 404) {
Request(
"PUT",
"/views?include_type_name=false"
).apply {
//language=JSON
setJsonEntity(
"""
{
"settings": {
"number_of_shards": 5
},
"mappings": {
"properties": {
"logged": {
"type": "boolean"
},
"type": {
"type": "keyword"
},
"user_ref": {
"type": "keyword"
},
"id": {
"type": "keyword"
},
"version_id": {
"type": "keyword"
},
"ip": {
"type": "keyword"
},
"citizen_id": {
"type": "keyword"
},
"view_at": {
"type": "date"
}
}
}
}
""".trimIndent()
)
}.let {
performRequest(it)
}
}
}
install(WebSockets) {
pingPeriod = Duration.ofSeconds(60) // Disabled (null) by default
timeout = Duration.ofSeconds(15)
@@ -233,7 +286,7 @@ fun Application.module(env: Env = PROD) {
install(Routing) {
// trace { application.log.trace(it.buildText()) }
authenticate(optional = true) {
article(get())
article(get(), get())
auth(get(), get(), get())
citizen(get(), get())
constitution(get())

View File

@@ -6,7 +6,6 @@ import io.ktor.application.ApplicationCall
import io.ktor.auth.authentication
import io.ktor.util.AttributeKey
import io.ktor.util.pipeline.PipelineContext
import kotlinx.coroutines.runBlocking
import org.koin.core.context.GlobalContext
import fr.dcproject.entity.Citizen as CitizenEntity
import fr.dcproject.repository.Citizen as CitizenRepository
@@ -15,11 +14,15 @@ private val citizenAttributeKey = AttributeKey<CitizenEntity>("CitizenContext")
val ApplicationCall.citizen: CitizenEntity
get() = attributes.computeIfAbsent(citizenAttributeKey) {
runBlocking {
val user = authentication.principal<UserI>() ?: throw ForbiddenException()
GlobalContext.get().koin.get<CitizenRepository>().findByUser(user)
?: throw ForbiddenException("Citizen not found for this user id \"${user.id}\"")
}
val user = authentication.principal<UserI>() ?: throw ForbiddenException()
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

View File

@@ -10,6 +10,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.rabbitmq.client.ConnectionFactory
import fr.dcproject.messages.Mailer
import fr.dcproject.messages.SsoManager
import fr.dcproject.views.ArticleViewManager
import fr.postgresjson.connexion.Connection
import fr.postgresjson.connexion.Requester
import fr.postgresjson.migration.Migrations
@@ -18,6 +19,8 @@ import io.ktor.client.features.websocket.WebSockets
import io.ktor.util.KtorExperimentalAPI
import io.lettuce.core.RedisClient
import io.lettuce.core.api.async.RedisAsyncCommands
import org.apache.http.HttpHost
import org.elasticsearch.client.RestClient
import org.koin.core.qualifier.named
import org.koin.dsl.module
import fr.dcproject.repository.Article as ArticleRepository
@@ -109,6 +112,15 @@ val Module = module {
single { OpinionChoiceRepository(get()) }
single { OpinionArticleRepository(get()) }
// Elasticsearch Client
single<RestClient> {
RestClient.builder(
HttpHost("localhost", 9200, "http")
).build()
}
single { ArticleViewManager(get()) }
// Mailler
single { Mailer(config.sendGridKey) }

View File

@@ -18,7 +18,8 @@ class Article(
override var lastVersion: Boolean = false,
createdBy: CitizenBasic
) : ArticleFull,
ArticleBasic(id, title, anonymous, content, description, tags, createdBy)
ArticleBasic(id, title, anonymous, content, description, tags, createdBy),
Viewable by ViewableImp()
open class ArticleBasic(
id: UUID = UUID.randomUUID(),
@@ -41,14 +42,20 @@ open class ArticleSimple(
override var title: String,
override val createdBy: CitizenBasic
) : ArticleSimpleI,
ArticleRef(id),
ArticleRefVersioning(id),
EntityCreatedAt by EntityCreatedAtImp(),
EntityCreatedBy<CitizenBasicI> by EntityCreatedByImp(createdBy),
EntityDeletedAt by EntityDeletedAtImp(),
EntityVersioning<UUID, Int> by UuidEntityVersioning(),
Votable by VotableImp(),
Opinionable by OpinionableImp()
open class ArticleRefVersioning(
id: UUID = UUID.randomUUID(),
versionNumber: Int? = null,
versionId: UUID = UUID.randomUUID()
) : ArticleRef(id),
EntityVersioning<UUID, Int> by UuidEntityVersioning(versionNumber, versionId)
open class ArticleRef(
id: UUID = UUID.randomUUID()
) : ArticleI, TargetRef(id)

View File

@@ -0,0 +1,13 @@
package fr.dcproject.entity
import fr.postgresjson.entity.EntityI
import fr.postgresjson.entity.immutable.EntityUpdatedAt
import fr.postgresjson.entity.immutable.EntityUpdatedAtImp
open class ViewAggregation(
val total: Int,
val unique: Int
) : EntityI,
EntityUpdatedAt by EntityUpdatedAtImp() {
constructor() : this(0, 0)
}

View File

@@ -0,0 +1,9 @@
package fr.dcproject.entity
interface Viewable {
var views: ViewAggregation
}
class ViewableImp : Viewable {
override var views: ViewAggregation = ViewAggregation()
}

View File

@@ -20,7 +20,7 @@ abstract class Event(
}
}
abstract class EntityEvent(
open class EntityEvent(
val target: UuidEntity,
type: String,
val action: String

View File

@@ -1,11 +1,13 @@
package fr.dcproject.routes
import fr.dcproject.citizen
import fr.dcproject.citizenOrNull
import fr.dcproject.event.ArticleUpdate
import fr.dcproject.repository.Article.Filter
import fr.dcproject.security.voter.ArticleVoter.Action.CREATE
import fr.dcproject.security.voter.ArticleVoter.Action.VIEW
import fr.dcproject.security.voter.assertCan
import fr.dcproject.views.ArticleViewManager
import fr.postgresjson.repository.RepositoryI
import io.ktor.application.application
import io.ktor.application.call
@@ -16,6 +18,7 @@ import io.ktor.locations.post
import io.ktor.request.receive
import io.ktor.response.respond
import io.ktor.routing.Route
import kotlinx.coroutines.launch
import fr.dcproject.entity.Article as ArticleEntity
import fr.dcproject.entity.request.Article as ArticleEntityRequest
import fr.dcproject.repository.Article as ArticleRepository
@@ -56,7 +59,7 @@ object ArticlesPaths {
}
@KtorExperimentalLocationsAPI
fun Route.article(repo: ArticleRepository) {
fun Route.article(repo: ArticleRepository, viewManager: ArticleViewManager) {
get<ArticlesPaths.ArticlesRequest> {
val articles =
repo.find(it.page, it.limit, it.sort, it.direction, it.search, Filter(createdById = it.createdBy))
@@ -67,7 +70,13 @@ fun Route.article(repo: ArticleRepository) {
get<ArticlesPaths.ArticleRequest> {
assertCan(VIEW, it.article)
it.article.views = viewManager.getViewsCount(it.article)
call.respond(it.article)
launch {
viewManager.addView(call.request.local.remoteHost, it.article, citizenOrNull)
}
}
get<ArticlesPaths.ArticleVersionsRequest> {

View File

@@ -0,0 +1,6 @@
package fr.dcproject.utils
import org.joda.time.DateTime
import org.joda.time.format.ISODateTimeFormat
fun DateTime.toIso() = ISODateTimeFormat.dateTime().print(this)

View File

@@ -0,0 +1,29 @@
package fr.dcproject.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
}
}

View File

@@ -0,0 +1,86 @@
package fr.dcproject.views
import fr.dcproject.entity.*
import fr.dcproject.utils.contentToString
import fr.dcproject.utils.getJsonField
import fr.dcproject.utils.toIso
import io.ktor.util.sha1
import org.elasticsearch.client.Request
import org.elasticsearch.client.Response
import org.elasticsearch.client.RestClient
import org.joda.time.DateTime
import java.util.*
class ArticleViewManager(private val restClient: RestClient) : ViewManager<ArticleRefVersioning> {
override fun addView(ip: String, article: ArticleRefVersioning, citizen: CitizenRef?, dateTime: DateTime): Response? {
val isLogged = (citizen != null).toString()
sha1("plop".toByteArray())
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": "${article.id}",
"version_id": "${article.versionId}",
"citizen_id": "${citizen?.id}",
"view_at": "${dateTime.toIso()}"
}
""".trimIndent())
}
return restClient.performRequest(request)
}
override fun getViewsCount(article: ArticleRefVersioning): ViewAggregation {
val request = Request(
"GET",
"/views/_search"
).apply {
//language=JSON
setJsonEntity("""
{
"size": 0,
"query": {
"bool": {
"must": {
"term": {
"version_id": "${article.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
)
}
}
}

View File

@@ -0,0 +1,11 @@
package fr.dcproject.views
import fr.dcproject.entity.CitizenRef
import fr.dcproject.entity.ViewAggregation
import org.elasticsearch.client.Response
import org.joda.time.DateTime
interface ViewManager <T> {
fun addView(ip: String, entity: T, citizen: CitizenRef? = null, dateTime: DateTime = DateTime.now()): Response?
fun getViewsCount(entity: T): ViewAggregation
}