#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

@@ -90,6 +90,8 @@ dependencies {
implementation("com.sendgrid:sendgrid-java:4.4.1") implementation("com.sendgrid:sendgrid-java:4.4.1")
implementation("io.lettuce:lettuce-core:5.2.2.RELEASE") implementation("io.lettuce:lettuce-core:5.2.2.RELEASE")
implementation("com.rabbitmq:amqp-client:5.8.0") implementation("com.rabbitmq:amqp-client:5.8.0")
implementation("org.elasticsearch.client:elasticsearch-rest-client:6.7.1")
implementation("com.jayway.jsonpath:json-path:2.4.0")
testImplementation("io.ktor:ktor-server-tests:$ktor_version") testImplementation("io.ktor:ktor-server-tests:$ktor_version")
testImplementation("io.ktor:ktor-client-mock:$ktor_version") testImplementation("io.ktor:ktor-client-mock:$ktor_version")

View File

@@ -36,6 +36,8 @@ import io.ktor.routing.Routing
import io.ktor.util.KtorExperimentalAPI import io.ktor.util.KtorExperimentalAPI
import io.ktor.websocket.WebSockets import io.ktor.websocket.WebSockets
import org.eclipse.jetty.util.log.Slf4jLog 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.core.qualifier.named
import org.koin.ktor.ext.Koin import org.koin.ktor.ext.Koin
import org.koin.ktor.ext.get 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) { install(WebSockets) {
pingPeriod = Duration.ofSeconds(60) // Disabled (null) by default pingPeriod = Duration.ofSeconds(60) // Disabled (null) by default
timeout = Duration.ofSeconds(15) timeout = Duration.ofSeconds(15)
@@ -233,7 +286,7 @@ fun Application.module(env: Env = PROD) {
install(Routing) { install(Routing) {
// trace { application.log.trace(it.buildText()) } // trace { application.log.trace(it.buildText()) }
authenticate(optional = true) { authenticate(optional = true) {
article(get()) article(get(), get())
auth(get(), get(), get()) auth(get(), get(), get())
citizen(get(), get()) citizen(get(), get())
constitution(get()) constitution(get())

View File

@@ -6,7 +6,6 @@ import io.ktor.application.ApplicationCall
import io.ktor.auth.authentication import io.ktor.auth.authentication
import io.ktor.util.AttributeKey import io.ktor.util.AttributeKey
import io.ktor.util.pipeline.PipelineContext import io.ktor.util.pipeline.PipelineContext
import kotlinx.coroutines.runBlocking
import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext
import fr.dcproject.entity.Citizen as CitizenEntity import fr.dcproject.entity.Citizen as CitizenEntity
import fr.dcproject.repository.Citizen as CitizenRepository import fr.dcproject.repository.Citizen as CitizenRepository
@@ -15,11 +14,15 @@ private val citizenAttributeKey = AttributeKey<CitizenEntity>("CitizenContext")
val ApplicationCall.citizen: CitizenEntity val ApplicationCall.citizen: CitizenEntity
get() = attributes.computeIfAbsent(citizenAttributeKey) { get() = attributes.computeIfAbsent(citizenAttributeKey) {
runBlocking { val user = authentication.principal<UserI>() ?: throw ForbiddenException()
val user = authentication.principal<UserI>() ?: throw ForbiddenException() GlobalContext.get().koin.get<CitizenRepository>().findByUser(user)
GlobalContext.get().koin.get<CitizenRepository>().findByUser(user) ?: throw ForbiddenException("Citizen not found for this user id \"${user.id}\"")
?: 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>.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 com.rabbitmq.client.ConnectionFactory
import fr.dcproject.messages.Mailer import fr.dcproject.messages.Mailer
import fr.dcproject.messages.SsoManager import fr.dcproject.messages.SsoManager
import fr.dcproject.views.ArticleViewManager
import fr.postgresjson.connexion.Connection import fr.postgresjson.connexion.Connection
import fr.postgresjson.connexion.Requester import fr.postgresjson.connexion.Requester
import fr.postgresjson.migration.Migrations import fr.postgresjson.migration.Migrations
@@ -18,6 +19,8 @@ import io.ktor.client.features.websocket.WebSockets
import io.ktor.util.KtorExperimentalAPI import io.ktor.util.KtorExperimentalAPI
import io.lettuce.core.RedisClient import io.lettuce.core.RedisClient
import io.lettuce.core.api.async.RedisAsyncCommands 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.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
import fr.dcproject.repository.Article as ArticleRepository import fr.dcproject.repository.Article as ArticleRepository
@@ -109,6 +112,15 @@ val Module = module {
single { OpinionChoiceRepository(get()) } single { OpinionChoiceRepository(get()) }
single { OpinionArticleRepository(get()) } single { OpinionArticleRepository(get()) }
// Elasticsearch Client
single<RestClient> {
RestClient.builder(
HttpHost("localhost", 9200, "http")
).build()
}
single { ArticleViewManager(get()) }
// Mailler // Mailler
single { Mailer(config.sendGridKey) } single { Mailer(config.sendGridKey) }

View File

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

View File

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

View File

@@ -258,6 +258,9 @@ paths:
parameters: parameters:
- $ref: '#/components/parameters/article' - $ref: '#/components/parameters/article'
get: get:
security:
- {}
- JWTAuth: []
summary: Get one article summary: Get one article
tags: tags:
- article - article

View File

@@ -25,7 +25,7 @@ var unitialized: Boolean = false
@KtorExperimentalAPI @KtorExperimentalAPI
@KtorExperimentalLocationsAPI @KtorExperimentalLocationsAPI
@RunWith(Cucumber::class) @RunWith(Cucumber::class)
@CucumberOptions(plugin = ["pretty"]) @CucumberOptions(plugin = ["pretty"], strict = true)
class RunCucumberTest : En, KoinTest { class RunCucumberTest : En, KoinTest {
private val logger: Logger? by LoggerDelegate() private val logger: Logger? by LoggerDelegate()

View File

@@ -0,0 +1,68 @@
import fr.dcproject.Env
import fr.dcproject.entity.ArticleRefVersioning
import fr.dcproject.entity.CitizenRef
import fr.dcproject.module
import fr.dcproject.views.ArticleViewManager
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.server.testing.withTestApplication
import io.ktor.util.KtorExperimentalAPI
import org.amshove.kluent.`should equal`
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS
import org.koin.ktor.ext.get
import java.util.*
@KtorExperimentalLocationsAPI
@KtorExperimentalAPI
@TestInstance(PER_CLASS)
class ViewTest {
@Test
fun `test View Article`() {
val article = ArticleRefVersioning(id = UUID.randomUUID(), versionId = UUID.randomUUID())
val citizenRef = CitizenRef()
withTestApplication({ module(Env.TEST) }) {
val viewManager: ArticleViewManager = application.get()
/* Get view before */
val startView = viewManager.getViewsCount(article)
/* Add View */
viewManager.addView(
"1.2.3.4",
article,
citizenRef
)
/* Add View */
viewManager.addView(
"10.10.10.10",
article,
citizenRef
)
/* Add View */
viewManager.addView(
"8.8.8.8",
article
)
/* Add View */
viewManager.addView(
"1.1.1.1",
article
)
/* Sleep because ES is not sync ! */
Thread.sleep(1000)
/* Get view */
val afterView = viewManager.getViewsCount(article)
/* Check if view has increment */
afterView.total `should equal` startView.total + 4
afterView.unique `should equal` startView.unique + 3
}
}
}

View File

@@ -1,3 +1,4 @@
@article
Feature: articles routes Feature: articles routes
Scenario: The route for get articles must response a 200 Scenario: The route for get articles must response a 200