#60 Can follow citizen #96

Merged
flecomte merged 5 commits from 60 into master 2021-04-27 21:48:16 +02:00
43 changed files with 1198 additions and 624 deletions

View File

@@ -426,7 +426,7 @@ dependencies {
testImplementation("io.insert-koin:koin-test:+") testImplementation("io.insert-koin:koin-test:+")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:+") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:+")
testImplementation("io.mockk:mockk:+") testImplementation("io.mockk:mockk:+")
testImplementation("org.junit.jupiter:junit-jupiter:+") testImplementation("org.junit.jupiter:junit-jupiter:5.7.+")
testImplementation("org.amshove.kluent:kluent:+") testImplementation("org.amshove.kluent:kluent:+")
testImplementation("io.mockk:mockk-agent-api:+") testImplementation("io.mockk:mockk-agent-api:+")
testImplementation("io.mockk:mockk-agent-jvm:+") testImplementation("io.mockk:mockk-agent-jvm:+")

View File

@@ -1,277 +1,131 @@
# This is a Gradle generated file for dependency locking. # This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised. # Manual edits can break the build and are not advised.
# This file is expected to be part of source control. # This file is expected to be part of source control.
ch.qos.logback:logback-classic:1.3.0-alpha5=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath ch.qos.logback:logback-classic:1.3.0-alpha5=compileClasspath
ch.qos.logback:logback-core:1.3.0-alpha5=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath ch.qos.logback:logback-core:1.3.0-alpha5=compileClasspath
com.auth0:java-jwt:3.15.0=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath com.auth0:java-jwt:3.15.0=compileClasspath
com.auth0:jwks-rsa:0.9.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.auth0:jwks-rsa:0.9.0=compileClasspath
com.avast.gradle:gradle-docker-compose-plugin:0.14.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath com.avast.gradle:gradle-docker-compose-plugin:0.14.3=compileClasspath
com.beust:jcommander:1.81=detekt com.fasterxml.jackson.core:jackson-annotations:2.12.3=compileClasspath
com.fasterxml.jackson.core:jackson-annotations:2.12.2=detekt com.fasterxml.jackson.core:jackson-core:2.12.3=compileClasspath
com.fasterxml.jackson.core:jackson-annotations:2.12.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath com.fasterxml.jackson.core:jackson-databind:2.12.3=compileClasspath
com.fasterxml.jackson.core:jackson-core:2.12.2=detekt com.fasterxml.jackson.datatype:jackson-datatype-joda:2.12.3=compileClasspath
com.fasterxml.jackson.core:jackson-core:2.12.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath com.fasterxml.jackson.module:jackson-module-kotlin:2.12.3=compileClasspath
com.fasterxml.jackson.core:jackson-databind:2.12.2=detekt com.fasterxml.jackson:jackson-bom:2.12.3=compileClasspath
com.fasterxml.jackson.core:jackson-databind:2.12.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath com.github.flecomte:postgres-json:2.1.2=compileClasspath
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.12.3=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath com.github.jasync-sql:jasync-common:1.1.7=compileClasspath
com.fasterxml.jackson.datatype:jackson-datatype-joda:2.12.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath com.github.jasync-sql:jasync-pool:1.1.7=compileClasspath
com.fasterxml.jackson.module:jackson-module-kotlin:2.12.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath com.github.jasync-sql:jasync-postgresql:1.1.7=compileClasspath
com.fasterxml.jackson:jackson-bom:2.12.2=detekt com.googlecode.json-simple:json-simple:1.1.1=compileClasspath
com.fasterxml.jackson:jackson-bom:2.12.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath com.jayway.jsonpath:json-path:2.5.0=compileClasspath
com.github.flecomte:postgres-json:2.1.2=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath com.ongres.scram:client:2.1=compileClasspath
com.github.jasync-sql:jasync-common:1.1.7=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath com.ongres.scram:common:2.1=compileClasspath
com.github.jasync-sql:jasync-pool:1.1.7=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath com.ongres.stringprep:saslprep:1.1=compileClasspath
com.github.jasync-sql:jasync-postgresql:1.1.7=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath com.ongres.stringprep:stringprep:1.1=compileClasspath
com.github.shyiko.klob:klob:0.2.1=ktlint com.rabbitmq:amqp-client:5.12.0=compileClasspath
com.google.code.findbugs:jsr305:3.0.2=runtimeClasspath,testRuntimeClasspath com.sendgrid:java-http-client:4.3.6=compileClasspath
com.google.errorprone:error_prone_annotations:2.2.0=runtimeClasspath,testRuntimeClasspath com.sendgrid:sendgrid-java:4.7.1=compileClasspath
com.google.guava:failureaccess:1.0.1=runtimeClasspath,testRuntimeClasspath com.typesafe:config:1.3.1=compileClasspath
com.google.guava:guava:27.1-jre=runtimeClasspath,testRuntimeClasspath commons-codec:commons-codec:1.11=compileClasspath
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=runtimeClasspath,testRuntimeClasspath commons-logging:commons-logging:1.2=compileClasspath
com.google.j2objc:j2objc-annotations:1.1=runtimeClasspath,testRuntimeClasspath edu.washington.cs.types.checker:checker-framework:1.7.0=compileClasspath
com.googlecode.json-simple:json-simple:1.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.github.microutils:kotlin-logging:1.7.6=compileClasspath
com.jayway.jsonpath:json-path:2.5.0=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath io.insert-koin:koin-core-ext:3.0.1=compileClasspath
com.ongres.scram:client:2.1=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath io.insert-koin:koin-core-jvm:3.0.1=compileClasspath
com.ongres.scram:common:2.1=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath io.insert-koin:koin-core:3.0.1=compileClasspath
com.ongres.stringprep:saslprep:1.1=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath io.insert-koin:koin-ktor:3.0.1=compileClasspath
com.ongres.stringprep:stringprep:1.1=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath io.konform:konform-jvm:0.3.0-RC1=compileClasspath
com.pinterest.ktlint:ktlint-core:0.40.0=ktlint io.ktor:ktor-auth-jwt:1.5.3=compileClasspath
com.pinterest.ktlint:ktlint-reporter-baseline:0.40.0=ktlint io.ktor:ktor-auth-kotlinMultiplatform:1.5.3=compileClasspath
com.pinterest.ktlint:ktlint-reporter-checkstyle:0.40.0=ktlint io.ktor:ktor-auth:1.5.3=compileClasspath
com.pinterest.ktlint:ktlint-reporter-html:0.40.0=ktlint io.ktor:ktor-client-core-jvm:1.5.3=compileClasspath
com.pinterest.ktlint:ktlint-reporter-json:0.40.0=ktlint io.ktor:ktor-client-core:1.5.3=compileClasspath
com.pinterest.ktlint:ktlint-reporter-plain:0.40.0=ktlint io.ktor:ktor-client-jetty:1.5.3=compileClasspath
com.pinterest.ktlint:ktlint-ruleset-experimental:0.40.0=ktlint io.ktor:ktor-http-cio-jvm:1.5.3=compileClasspath
com.pinterest.ktlint:ktlint-ruleset-standard:0.40.0=ktlint io.ktor:ktor-http-cio:1.5.3=compileClasspath
com.pinterest.ktlint:ktlint-ruleset-test:0.40.0=ktlint io.ktor:ktor-http-jvm:1.5.3=compileClasspath
com.pinterest:ktlint:0.40.0=ktlint io.ktor:ktor-http:1.5.3=compileClasspath
com.rabbitmq:amqp-client:5.12.0=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath io.ktor:ktor-io-jvm:1.5.3=compileClasspath
com.sendgrid:java-http-client:4.3.6=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath io.ktor:ktor-io:1.5.3=compileClasspath
com.sendgrid:sendgrid-java:4.7.1=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath io.ktor:ktor-jackson:1.5.3=compileClasspath
com.sun.mail:javax.mail:1.6.2=runtimeClasspath,testRuntimeClasspath io.ktor:ktor-locations:1.5.3=compileClasspath
com.thedeanda:lorem:2.1=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath io.ktor:ktor-network-jvm:1.5.3=compileClasspath
com.typesafe:config:1.3.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.ktor:ktor-network:1.5.3=compileClasspath
commons-codec:commons-codec:1.11=compileClasspath,implementationDependenciesMetadata,testCompileClasspath,testImplementationDependenciesMetadata io.ktor:ktor-server-core-kotlinMultiplatform:1.5.3=compileClasspath
commons-codec:commons-codec:1.13=runtimeClasspath,testRuntimeClasspath io.ktor:ktor-server-core:1.5.3=compileClasspath
commons-io:commons-io:2.6=runtimeClasspath,testRuntimeClasspath io.ktor:ktor-server-host-common-kotlinMultiplatform:1.5.3=compileClasspath
commons-logging:commons-logging:1.2=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath io.ktor:ktor-server-host-common:1.5.3=compileClasspath
edu.washington.cs.types.checker:checker-framework:1.7.0=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath io.ktor:ktor-server-jetty:1.5.3=compileClasspath
info.picocli:picocli:3.9.6=ktlint io.ktor:ktor-server-servlet-kotlinMultiplatform:1.5.3=compileClasspath
io.github.detekt.sarif4j:sarif4j:1.0.0=detekt io.ktor:ktor-server-servlet:1.5.3=compileClasspath
io.github.microutils:kotlin-logging:1.7.6=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath io.ktor:ktor-utils-jvm:1.5.3=compileClasspath
io.gitlab.arturbosch.detekt:detekt-api:1.16.0=detekt io.ktor:ktor-utils:1.5.3=compileClasspath
io.gitlab.arturbosch.detekt:detekt-bom:1.16.0=detekt io.ktor:ktor-websockets:1.5.3=compileClasspath
io.gitlab.arturbosch.detekt:detekt-cli:1.16.0=detekt io.lettuce:lettuce-core:5.3.6.RELEASE=compileClasspath
io.gitlab.arturbosch.detekt:detekt-core:1.16.0=detekt io.netty:netty-buffer:4.1.56.Final=compileClasspath
io.gitlab.arturbosch.detekt:detekt-metrics:1.16.0=detekt io.netty:netty-codec:4.1.56.Final=compileClasspath
io.gitlab.arturbosch.detekt:detekt-parser:1.16.0=detekt io.netty:netty-common:4.1.56.Final=compileClasspath
io.gitlab.arturbosch.detekt:detekt-psi-utils:1.16.0=detekt io.netty:netty-handler:4.1.56.Final=compileClasspath
io.gitlab.arturbosch.detekt:detekt-report-html:1.16.0=detekt io.netty:netty-resolver:4.1.56.Final=compileClasspath
io.gitlab.arturbosch.detekt:detekt-report-sarif:1.16.0=detekt io.netty:netty-transport:4.1.56.Final=compileClasspath
io.gitlab.arturbosch.detekt:detekt-report-txt:1.16.0=detekt io.projectreactor:reactor-core:3.4.1=compileClasspath
io.gitlab.arturbosch.detekt:detekt-report-xml:1.16.0=detekt javax.servlet:javax.servlet-api:3.1.0=compileClasspath
io.gitlab.arturbosch.detekt:detekt-rules-complexity:1.16.0=detekt joda-time:joda-time:2.10.8=compileClasspath
io.gitlab.arturbosch.detekt:detekt-rules-coroutines:1.16.0=detekt net.minidev:accessors-smart:1.2=compileClasspath
io.gitlab.arturbosch.detekt:detekt-rules-documentation:1.16.0=detekt net.minidev:json-smart:2.3=compileClasspath
io.gitlab.arturbosch.detekt:detekt-rules-empty:1.16.0=detekt net.pearx.kasechange:kasechange-jvm:1.3.0=compileClasspath
io.gitlab.arturbosch.detekt:detekt-rules-errorprone:1.16.0=detekt org.apache.httpcomponents:httpasyncclient:4.1.2=compileClasspath
io.gitlab.arturbosch.detekt:detekt-rules-exceptions:1.16.0=detekt org.apache.httpcomponents:httpclient:4.5.12=compileClasspath
io.gitlab.arturbosch.detekt:detekt-rules-naming:1.16.0=detekt org.apache.httpcomponents:httpcore-nio:4.4.5=compileClasspath
io.gitlab.arturbosch.detekt:detekt-rules-performance:1.16.0=detekt org.apache.httpcomponents:httpcore:4.4.13=compileClasspath
io.gitlab.arturbosch.detekt:detekt-rules-style:1.16.0=detekt org.bouncycastle:bcprov-jdk15on:1.67=compileClasspath
io.gitlab.arturbosch.detekt:detekt-rules:1.16.0=detekt org.eclipse.jetty.http2:http2-client:9.4.31.v20200723=compileClasspath
io.gitlab.arturbosch.detekt:detekt-tooling:1.16.0=detekt org.eclipse.jetty.http2:http2-common:9.4.31.v20200723=compileClasspath
io.insert-koin:koin-core-ext:3.0.1=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.eclipse.jetty.http2:http2-hpack:9.4.31.v20200723=compileClasspath
io.insert-koin:koin-core-jvm:3.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.eclipse.jetty.http2:http2-server:9.4.31.v20200723=compileClasspath
io.insert-koin:koin-core:3.0.1=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.eclipse.jetty:jetty-alpn-client:9.4.31.v20200723=compileClasspath
io.insert-koin:koin-ktor:3.0.1=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.eclipse.jetty:jetty-alpn-java-client:9.4.31.v20200723=compileClasspath
io.insert-koin:koin-test-jvm:3.0.1=testCompileClasspath,testRuntimeClasspath org.eclipse.jetty:jetty-alpn-java-server:9.4.31.v20200723=compileClasspath
io.insert-koin:koin-test:3.0.1=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.eclipse.jetty:jetty-alpn-openjdk8-client:9.4.31.v20200723=compileClasspath
io.konform:konform-jvm:0.3.0-RC1=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.eclipse.jetty:jetty-alpn-openjdk8-server:9.4.31.v20200723=compileClasspath
io.konform:konform-metadata:0.3.0-RC1=implementationDependenciesMetadata,testImplementationDependenciesMetadata org.eclipse.jetty:jetty-alpn-server:9.4.31.v20200723=compileClasspath
io.ktor:ktor-auth-jwt-kotlinMultiplatform:1.5.3=implementationDependenciesMetadata,testImplementationDependenciesMetadata org.eclipse.jetty:jetty-continuation:9.4.31.v20200723=compileClasspath
io.ktor:ktor-auth-jwt:1.5.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.eclipse.jetty:jetty-http:9.4.31.v20200723=compileClasspath
io.ktor:ktor-auth-kotlinMultiplatform:1.5.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.eclipse.jetty:jetty-io:9.4.31.v20200723=compileClasspath
io.ktor:ktor-auth:1.5.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.eclipse.jetty:jetty-server:9.4.31.v20200723=compileClasspath
io.ktor:ktor-client-cio-jvm:1.5.3=testCompileClasspath,testRuntimeClasspath org.eclipse.jetty:jetty-servlets:9.4.31.v20200723=compileClasspath
io.ktor:ktor-client-cio:1.5.3=testCompileClasspath,testRuntimeClasspath org.eclipse.jetty:jetty-util:9.4.31.v20200723=compileClasspath
io.ktor:ktor-client-core-jvm:1.5.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.elasticsearch.client:elasticsearch-rest-client:6.8.15=compileClasspath
io.ktor:ktor-client-core:1.5.3=compileClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.jetbrains.intellij.deps:trove4j:1.0.20181211=kotlinCompilerClasspath
io.ktor:ktor-client-jetty-kotlinMultiplatform:1.5.3=implementationDependenciesMetadata,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.jetbrains.kotlin:kotlin-compiler-embeddable:1.4.32=kotlinCompilerClasspath
io.ktor:ktor-client-jetty:1.5.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.jetbrains.kotlin:kotlin-daemon-embeddable:1.4.32=kotlinCompilerClasspath
io.ktor:ktor-client-mock-jvm:1.5.3=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.ktor:ktor-client-mock:1.5.3=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.ktor:ktor-http-cio-jvm:1.5.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.ktor:ktor-http-cio:1.5.3=compileClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.ktor:ktor-http-jvm:1.5.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.ktor:ktor-http:1.5.3=compileClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.ktor:ktor-io-jvm:1.5.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.ktor:ktor-io:1.5.3=compileClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.ktor:ktor-jackson-kotlinMultiplatform:1.5.3=implementationDependenciesMetadata,testImplementationDependenciesMetadata
io.ktor:ktor-jackson:1.5.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.ktor:ktor-locations-kotlinMultiplatform:1.5.3=implementationDependenciesMetadata,testImplementationDependenciesMetadata
io.ktor:ktor-locations:1.5.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.ktor:ktor-network-jvm:1.5.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.ktor:ktor-network-tls-certificates-kotlinMultiplatform:1.5.3=testCompileClasspath,testRuntimeClasspath
io.ktor:ktor-network-tls-certificates:1.5.3=testCompileClasspath,testRuntimeClasspath
io.ktor:ktor-network-tls-jvm:1.5.3=testCompileClasspath,testRuntimeClasspath
io.ktor:ktor-network-tls:1.5.3=testCompileClasspath,testRuntimeClasspath
io.ktor:ktor-network:1.5.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.ktor:ktor-server-core-kotlinMultiplatform:1.5.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.ktor:ktor-server-core:1.5.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.ktor:ktor-server-host-common-kotlinMultiplatform:1.5.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.ktor:ktor-server-host-common:1.5.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.ktor:ktor-server-jetty-kotlinMultiplatform:1.5.3=implementationDependenciesMetadata,testImplementationDependenciesMetadata
io.ktor:ktor-server-jetty:1.5.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.ktor:ktor-server-servlet-kotlinMultiplatform:1.5.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.ktor:ktor-server-servlet:1.5.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.ktor:ktor-server-test-host-kotlinMultiplatform:1.5.3=testCompileClasspath,testRuntimeClasspath
io.ktor:ktor-server-test-host:1.5.3=testCompileClasspath,testRuntimeClasspath
io.ktor:ktor-server-tests-kotlinMultiplatform:1.5.3=testImplementationDependenciesMetadata
io.ktor:ktor-server-tests:1.5.3=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.ktor:ktor-utils-jvm:1.5.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.ktor:ktor-utils:1.5.3=compileClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.ktor:ktor-websockets-kotlinMultiplatform:1.5.3=implementationDependenciesMetadata,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.ktor:ktor-websockets:1.5.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.lettuce:lettuce-core:5.3.6.RELEASE=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.mockk:mockk-agent-api:1.11.0=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.mockk:mockk-agent-common:1.11.0=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.mockk:mockk-agent-jvm:1.11.0=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.mockk:mockk-common:1.11.0=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.mockk:mockk-dsl-jvm:1.11.0=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.mockk:mockk-dsl:1.11.0=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.mockk:mockk:1.11.0=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.netty:netty-buffer:4.1.56.Final=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.netty:netty-codec:4.1.56.Final=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.netty:netty-common:4.1.56.Final=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.netty:netty-handler:4.1.56.Final=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.netty:netty-resolver:4.1.56.Final=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.netty:netty-transport:4.1.56.Final=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
io.projectreactor:reactor-core:3.3.13.RELEASE=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
javax.activation:activation:1.1=runtimeClasspath,testRuntimeClasspath
javax.servlet:javax.servlet-api:3.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
joda-time:joda-time:2.10.8=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
junit:junit:4.13.1=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy-agent:1.10.14=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.10.14=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
net.java.dev.jna:jna-platform:5.5.0=testRuntimeClasspath
net.java.dev.jna:jna:5.5.0=testRuntimeClasspath
net.minidev:accessors-smart:1.2=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
net.minidev:json-smart:2.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
net.pearx.kasechange:kasechange-jvm:1.3.0=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
net.pearx.kasechange:kasechange-metadata:1.3.0=implementationDependenciesMetadata,testImplementationDependenciesMetadata
org.amshove.kluent:kluent-common:1.65=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.amshove.kluent:kluent:1.65=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.apache.httpcomponents:httpasyncclient:4.1.2=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.apache.httpcomponents:httpclient:4.5.12=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.apache.httpcomponents:httpcore-nio:4.4.5=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.apache.httpcomponents:httpcore:4.4.13=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.apiguardian:apiguardian-api:1.1.1=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.bouncycastle:bcprov-jdk15on:1.67=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.checkerframework:checker-qual:2.5.2=runtimeClasspath,testRuntimeClasspath
org.codehaus.mojo:animal-sniffer-annotations:1.17=runtimeClasspath,testRuntimeClasspath
org.ec4j.core:ec4j-core:0.2.2=ktlint
org.eclipse.jetty.http2:http2-client:9.4.31.v20200723=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.http2:http2-common:9.4.31.v20200723=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.http2:http2-hpack:9.4.31.v20200723=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.http2:http2-http-client-transport:9.4.31.v20200723=testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.http2:http2-server:9.4.31.v20200723=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-alpn-client:9.4.31.v20200723=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-alpn-java-client:9.4.31.v20200723=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-alpn-java-server:9.4.31.v20200723=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-alpn-openjdk8-client:9.4.31.v20200723=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-alpn-openjdk8-server:9.4.31.v20200723=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-alpn-server:9.4.31.v20200723=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-client:9.4.31.v20200723=testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-continuation:9.4.31.v20200723=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-http:9.4.31.v20200723=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-io:9.4.31.v20200723=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-server:9.4.31.v20200723=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-servlets:9.4.31.v20200723=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-util:9.4.31.v20200723=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.elasticsearch.client:elasticsearch-rest-client:6.8.15=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.hamcrest:hamcrest-core:1.3=testCompileClasspath,testRuntimeClasspath
org.jacoco:org.jacoco.agent:0.8.6=jacocoAgent,jacocoAnt
org.jacoco:org.jacoco.ant:0.8.6=jacocoAnt
org.jacoco:org.jacoco.core:0.8.6=jacocoAnt
org.jacoco:org.jacoco.report:0.8.6=jacocoAnt
org.jetbrains.intellij.deps:trove4j:1.0.20181211=detekt,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath,ktlint
org.jetbrains.kotlin:kotlin-compiler-embeddable:1.4.10=ktlint
org.jetbrains.kotlin:kotlin-compiler-embeddable:1.4.21=detekt
org.jetbrains.kotlin:kotlin-compiler-embeddable:1.4.32=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
org.jetbrains.kotlin:kotlin-daemon-embeddable:1.4.10=ktlint
org.jetbrains.kotlin:kotlin-daemon-embeddable:1.4.21=detekt
org.jetbrains.kotlin:kotlin-daemon-embeddable:1.4.32=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
org.jetbrains.kotlin:kotlin-gradle-plugin-api:1.4.32=kotlinCompilerPluginClasspath org.jetbrains.kotlin:kotlin-gradle-plugin-api:1.4.32=kotlinCompilerPluginClasspath
org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:1.4.32=kotlinKlibCommonizerClasspath org.jetbrains.kotlin:kotlin-reflect:1.4.32=compileClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-reflect:1.4.10=ktlint org.jetbrains.kotlin:kotlin-script-runtime:1.4.32=kotlinCompilerClasspath,kotlinCompilerPluginClasspath
org.jetbrains.kotlin:kotlin-reflect:1.4.21=detekt,implementationDependenciesMetadata
org.jetbrains.kotlin:kotlin-reflect:1.4.32=compileClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-script-runtime:1.4.10=ktlint
org.jetbrains.kotlin:kotlin-script-runtime:1.4.21=detekt
org.jetbrains.kotlin:kotlin-script-runtime:1.4.32=kotlinCompilerClasspath,kotlinCompilerPluginClasspath,kotlinKlibCommonizerClasspath
org.jetbrains.kotlin:kotlin-scripting-common:1.4.32=kotlinCompilerPluginClasspath org.jetbrains.kotlin:kotlin-scripting-common:1.4.32=kotlinCompilerPluginClasspath
org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:1.4.32=kotlinCompilerPluginClasspath org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:1.4.32=kotlinCompilerPluginClasspath
org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:1.4.32=kotlinCompilerPluginClasspath org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:1.4.32=kotlinCompilerPluginClasspath
org.jetbrains.kotlin:kotlin-scripting-jvm:1.4.32=kotlinCompilerPluginClasspath org.jetbrains.kotlin:kotlin-scripting-jvm:1.4.32=kotlinCompilerPluginClasspath
org.jetbrains.kotlin:kotlin-serialization-unshaded:1.4.32=kotlinNativeCompilerPluginClasspath
org.jetbrains.kotlin:kotlin-serialization:1.4.32=kotlinCompilerPluginClasspath org.jetbrains.kotlin:kotlin-serialization:1.4.32=kotlinCompilerPluginClasspath
org.jetbrains.kotlin:kotlin-stdlib-common:1.4.10=ktlint org.jetbrains.kotlin:kotlin-stdlib-common:1.4.32=kotlinCompilerClasspath,kotlinCompilerPluginClasspath
org.jetbrains.kotlin:kotlin-stdlib-common:1.4.21=detekt org.jetbrains.kotlin:kotlin-stdlib-common:1.5.0=compileClasspath
org.jetbrains.kotlin:kotlin-stdlib-common:1.4.32=compileClasspath,implementationDependenciesMetadata,kotlinCompilerClasspath,kotlinCompilerPluginClasspath,kotlinKlibCommonizerClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.5.0=compileClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.4.21=detekt org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.5.0=compileClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.4.32=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.jetbrains.kotlin:kotlin-stdlib:1.4.32=kotlinCompilerClasspath,kotlinCompilerPluginClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.21=detekt org.jetbrains.kotlin:kotlin-stdlib:1.5.0=compileClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.32=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.5.0-RC=compileClasspath
org.jetbrains.kotlin:kotlin-stdlib:1.4.10=ktlint
org.jetbrains.kotlin:kotlin-stdlib:1.4.21=detekt
org.jetbrains.kotlin:kotlin-stdlib:1.4.32=compileClasspath,implementationDependenciesMetadata,kotlinCompilerClasspath,kotlinCompilerPluginClasspath,kotlinKlibCommonizerClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-test-annotations-common:1.4.30=testImplementationDependenciesMetadata
org.jetbrains.kotlin:kotlin-test-annotations-common:1.4.32=testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-test-common:1.4.30=testImplementationDependenciesMetadata
org.jetbrains.kotlin:kotlin-test-common:1.4.32=testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-test-junit:1.4.32=testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-test:1.4.32=testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.4.2=implementationDependenciesMetadata,testImplementationDependenciesMetadata
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.4.3-native-mt=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-core-metadata:1.4.2=implementationDependenciesMetadata,testImplementationDependenciesMetadata
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.8=kotlinCompilerPluginClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.8=kotlinCompilerPluginClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2=implementationDependenciesMetadata org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0-RC=compileClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3-native-mt=compileClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.4.3-native-mt=compileClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-debug:1.4.3-native-mt=testRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-reactive:1.5.0-RC=compileClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.4.3-native-mt=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.5.0-RC=compileClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-reactive:1.4.2=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.2.0=compileClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.4.2=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.jetbrains.kotlinx:kotlinx-serialization-core:1.2.0=compileClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.2=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.2.0=compileClasspath
org.jetbrains.kotlinx:kotlinx-html-jvm:0.7.2=detekt org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.0=compileClasspath
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jetbrains:annotations:13.0=compileClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspath
org.jetbrains.kotlinx:kotlinx-serialization-core-metadata:1.0.1=implementationDependenciesMetadata,testImplementationDependenciesMetadata org.joda:joda-convert:1.8.1=compileClasspath
org.jetbrains.kotlinx:kotlinx-serialization-core:1.0.1=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.ow2.asm:asm:5.0.4=compileClasspath
org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.reactivestreams:reactive-streams:1.0.3=compileClasspath
org.jetbrains.kotlinx:kotlinx-serialization-json-metadata:1.0.1=implementationDependenciesMetadata,testImplementationDependenciesMetadata org.slf4j:slf4j-api:2.0.0-alpha1=compileClasspath
org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath empty=
org.jetbrains:annotations:13.0=compileClasspath,detekt,implementationDependenciesMetadata,kotlinCompilerClasspath,kotlinCompilerPluginClasspath,kotlinKlibCommonizerClasspath,ktlint,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.joda:joda-convert:1.8.1=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:5.8.0-M1=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:5.8.0-M1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:5.8.0-M1=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:5.8.0-M1=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.junit.platform:junit-platform-commons:1.8.0-M1=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.junit.platform:junit-platform-engine:1.8.0-M1=testRuntimeClasspath
org.junit:junit-bom:5.8.0-M1=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.objenesis:objenesis:3.1=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.openapi4j:openapi-core:1.0.7=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.openapi4j:openapi-operation-validator:1.0.7=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.openapi4j:openapi-parser:1.0.7=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.openapi4j:openapi-schema-validator:1.0.7=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.opentest4j:opentest4j:1.2.0=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.ow2.asm:asm-analysis:8.0.1=jacocoAnt
org.ow2.asm:asm-commons:8.0.1=jacocoAnt
org.ow2.asm:asm-tree:8.0.1=jacocoAnt
org.ow2.asm:asm:5.0.4=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.ow2.asm:asm:8.0.1=jacocoAnt
org.reactivestreams:reactive-streams:1.0.3=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.0-alpha1=compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
org.yaml:snakeyaml:1.27=testCompileClasspath,testImplementationDependenciesMetadata
org.yaml:snakeyaml:1.28=detekt,runtimeClasspath,testRuntimeClasspath
empty=annotationProcessor,apiDependenciesMetadata,compile,compileOnly,compileOnlyDependenciesMetadata,detektPlugins,kotlinScriptDef,kotlinScriptDefExtensions,ktlintReporter,ktlintRuleset,runtime,runtimeOnlyDependenciesMetadata,shadow,testAnnotationProcessor,testApiDependenciesMetadata,testCompile,testCompileOnly,testCompileOnlyDependenciesMetadata,testKotlinScriptDef,testKotlinScriptDefExtensions,testRuntime,testRuntimeOnlyDependenciesMetadata

View File

@@ -9,6 +9,7 @@ import com.fasterxml.jackson.datatype.joda.JodaModule
import fr.dcproject.application.Env.PROD import fr.dcproject.application.Env.PROD
import fr.dcproject.application.Env.TEST import fr.dcproject.application.Env.TEST
import fr.dcproject.application.http.statusPagesInstallation import fr.dcproject.application.http.statusPagesInstallation
import fr.dcproject.common.utils.onApplicationStopped
import fr.dcproject.component.article.articleKoinModule import fr.dcproject.component.article.articleKoinModule
import fr.dcproject.component.article.routes.installArticleRoutes import fr.dcproject.component.article.routes.installArticleRoutes
import fr.dcproject.component.auth.authKoinModule import fr.dcproject.component.auth.authKoinModule
@@ -25,8 +26,10 @@ import fr.dcproject.component.constitution.routes.installConstitutionRoutes
import fr.dcproject.component.doc.routes.installDocRoutes import fr.dcproject.component.doc.routes.installDocRoutes
import fr.dcproject.component.follow.followKoinModule import fr.dcproject.component.follow.followKoinModule
import fr.dcproject.component.follow.routes.article.installFollowArticleRoutes import fr.dcproject.component.follow.routes.article.installFollowArticleRoutes
import fr.dcproject.component.follow.routes.citizen.installFollowCitizenRoutes
import fr.dcproject.component.follow.routes.constitution.installFollowConstitutionRoutes import fr.dcproject.component.follow.routes.constitution.installFollowConstitutionRoutes
import fr.dcproject.component.notification.NotificationConsumer import fr.dcproject.component.notification.email.NotificationEmailConsumer
import fr.dcproject.component.notification.push.NotificationPushConsumer
import fr.dcproject.component.notification.routes.installNotificationsRoutes import fr.dcproject.component.notification.routes.installNotificationsRoutes
import fr.dcproject.component.opinion.opinionKoinModule import fr.dcproject.component.opinion.opinionKoinModule
import fr.dcproject.component.opinion.routes.installOpinionRoutes import fr.dcproject.component.opinion.routes.installOpinionRoutes
@@ -37,7 +40,6 @@ import fr.dcproject.component.workgroup.routes.installWorkgroupRoutes
import fr.dcproject.component.workgroup.workgroupKoinModule import fr.dcproject.component.workgroup.workgroupKoinModule
import fr.postgresjson.migration.Migrations import fr.postgresjson.migration.Migrations
import io.ktor.application.Application import io.ktor.application.Application
import io.ktor.application.ApplicationStopped
import io.ktor.application.install import io.ktor.application.install
import io.ktor.auth.Authentication import io.ktor.auth.Authentication
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
@@ -117,11 +119,14 @@ fun Application.module(env: Env = PROD) {
masking = false masking = false
} }
get<NotificationConsumer>().run { get<NotificationEmailConsumer>().run {
start() start()
environment.monitor.subscribe(ApplicationStopped) { onApplicationStopped { close() }
close()
} }
get<NotificationPushConsumer>().run {
start()
onApplicationStopped { close() }
} }
install(Authentication, jwtInstallation(get(), get())) install(Authentication, jwtInstallation(get(), get()))
@@ -154,6 +159,7 @@ fun Application.module(env: Env = PROD) {
installCommentRoutes() installCommentRoutes()
installFollowArticleRoutes() installFollowArticleRoutes()
installFollowConstitutionRoutes() installFollowConstitutionRoutes()
installFollowCitizenRoutes()
installWorkgroupRoutes() installWorkgroupRoutes()
installOpinionRoutes() installOpinionRoutes()
installVoteRoutes() installVoteRoutes()

View File

@@ -10,10 +10,11 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.rabbitmq.client.ConnectionFactory import com.rabbitmq.client.ConnectionFactory
import fr.dcproject.common.email.Mailer import fr.dcproject.common.email.Mailer
import fr.dcproject.component.auth.jwt.JwtConfig import fr.dcproject.component.auth.jwt.JwtConfig
import fr.dcproject.component.notification.NotificationConsumer import fr.dcproject.component.notification.NotificationPublisherAsync
import fr.dcproject.component.notification.NotificationEmailSender import fr.dcproject.component.notification.email.NotificationEmailConsumer
import fr.dcproject.component.notification.NotificationsPush import fr.dcproject.component.notification.email.NotificationEmailSender
import fr.dcproject.component.notification.Publisher import fr.dcproject.component.notification.push.NotificationPushConsumer
import fr.dcproject.component.notification.push.NotificationPushListener
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
@@ -65,11 +66,15 @@ val KoinModule = module {
} }
} }
single { NotificationsPush.Builder(get()) } single { NotificationPushListener.Builder(get()) }
single { single {
val config: Configuration = get() val config: Configuration = get()
NotificationConsumer(get(), get(), get(), get(), get(), config.exchangeNotificationName) NotificationEmailConsumer(get(), get(), get(), get(), get(), config.exchangeNotificationName)
}
single {
val config: Configuration = get()
NotificationPushConsumer(get(), get(), get(), get(), get(), config.exchangeNotificationName)
} }
// RabbitMQ // RabbitMQ
@@ -114,7 +119,7 @@ val KoinModule = module {
single { single {
val config: Configuration = get() val config: Configuration = get()
Publisher(factory = get(), exchangeName = config.exchangeNotificationName) NotificationPublisherAsync(factory = get(), exchangeName = config.exchangeNotificationName)
} }
single { single {

View File

@@ -9,6 +9,9 @@ import java.io.IOException
class Mailer( class Mailer(
private val key: String private val key: String
) { ) {
/**
* Send email via Sendgrid
*/
fun sendEmail(action: () -> Mail): Boolean { fun sendEmail(action: () -> Mail): Boolean {
val mail = action() val mail = action()

View File

@@ -2,6 +2,7 @@ package fr.dcproject.common.entity
import fr.dcproject.component.article.database.ArticleRef import fr.dcproject.component.article.database.ArticleRef
import fr.dcproject.component.citizen.database.CitizenI import fr.dcproject.component.citizen.database.CitizenI
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.comment.generic.database.CommentRef import fr.dcproject.component.comment.generic.database.CommentRef
import fr.dcproject.component.constitution.database.ConstitutionRef import fr.dcproject.component.constitution.database.ConstitutionRef
import fr.dcproject.component.opinion.database.OpinionRef import fr.dcproject.component.opinion.database.OpinionRef
@@ -34,7 +35,8 @@ interface TargetI : EntityI {
Article("article"), Article("article"),
Constitution("constitution"), Constitution("constitution"),
Comment("comment"), Comment("comment"),
Opinion("opinion") Opinion("opinion"),
Citizen("citizen"),
} }
companion object { companion object {
@@ -44,6 +46,7 @@ interface TargetI : EntityI {
t.isSubclassOf(ConstitutionRef::class) -> TargetName.Constitution.targetReference t.isSubclassOf(ConstitutionRef::class) -> TargetName.Constitution.targetReference
t.isSubclassOf(CommentRef::class) -> TargetName.Comment.targetReference t.isSubclassOf(CommentRef::class) -> TargetName.Comment.targetReference
t.isSubclassOf(OpinionRef::class) -> TargetName.Opinion.targetReference t.isSubclassOf(OpinionRef::class) -> TargetName.Opinion.targetReference
t.isSubclassOf(CitizenRef::class) -> TargetName.Citizen.targetReference
else -> throw error("target not implemented: ${t.qualifiedName} \nImplement it or return 'reference' from SQL") else -> throw error("target not implemented: ${t.qualifiedName} \nImplement it or return 'reference' from SQL")
} }
} }

View File

@@ -0,0 +1,10 @@
package fr.dcproject.common.utils
import io.ktor.application.Application
import io.ktor.application.ApplicationStopped
fun Application.onApplicationStopped(callback: Application.() -> Unit) {
environment.monitor.subscribe(ApplicationStopped) {
callback()
}
}

View File

@@ -0,0 +1,30 @@
package fr.dcproject.common.utils
import com.rabbitmq.client.AMQP
import com.rabbitmq.client.Channel
import com.rabbitmq.client.Consumer
import com.rabbitmq.client.DefaultConsumer
import com.rabbitmq.client.Envelope
import kotlinx.coroutines.runBlocking
import java.io.IOException
fun Channel.consumeQueue(queueName: String, callback: DefaultConsumer.(ByteArray) -> Unit) {
val consumer: Consumer = object : DefaultConsumer(this) {
@Throws(IOException::class)
override fun handleDelivery(
consumerTag: String,
envelope: Envelope,
properties: AMQP.BasicProperties,
body: ByteArray
) = runBlocking {
try {
callback(body)
basicAck(envelope.deliveryTag, false)
} catch (e: Throwable) {
basicNack(envelope.deliveryTag, false, true)
}
}
}
/* Launch Consumer */
basicConsume(queueName, false, consumer)
}

View File

@@ -10,8 +10,8 @@ import fr.dcproject.component.article.routes.UpsertArticle.UpsertArticleRequest.
import fr.dcproject.component.auth.citizen import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.notification.ArticleUpdateNotification import fr.dcproject.component.notification.ArticleUpdateNotificationMessage
import fr.dcproject.component.notification.Publisher import fr.dcproject.component.notification.NotificationPublisherAsync
import fr.dcproject.component.workgroup.database.WorkgroupRef import fr.dcproject.component.workgroup.database.WorkgroupRef
import io.konform.validation.Validation import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxItems import io.konform.validation.jsonschema.maxItems
@@ -63,7 +63,7 @@ object UpsertArticle {
} }
} }
fun Route.upsertArticle(repo: ArticleRepository, publisher: Publisher, ac: ArticleAccessControl) { fun Route.upsertArticle(repo: ArticleRepository, notificationPublisher: NotificationPublisherAsync, ac: ArticleAccessControl) {
suspend fun ApplicationCall.convertRequestToEntity(): ArticleForUpdate = receiveOrBadRequest<Input>().run { suspend fun ApplicationCall.convertRequestToEntity(): ArticleForUpdate = receiveOrBadRequest<Input>().run {
validate().badRequestIfNotValid() validate().badRequestIfNotValid()
ArticleForUpdate( ArticleForUpdate(
@@ -92,7 +92,7 @@ object UpsertArticle {
val versionNumber = a.versionNumber val versionNumber = a.versionNumber
} }
) )
publisher.publish(ArticleUpdateNotification(a)) notificationPublisher.publishAsync(ArticleUpdateNotificationMessage(a))
} ?: error("Article not updated") } ?: error("Article not updated")
} }
} }

View File

@@ -2,8 +2,9 @@ package fr.dcproject.component.citizen.database
import fr.dcproject.common.entity.CreatedAt import fr.dcproject.common.entity.CreatedAt
import fr.dcproject.common.entity.DeletedAt import fr.dcproject.common.entity.DeletedAt
import fr.dcproject.common.entity.Entity
import fr.dcproject.common.entity.EntityI import fr.dcproject.common.entity.EntityI
import fr.dcproject.common.entity.TargetI
import fr.dcproject.common.entity.TargetRef
import fr.dcproject.component.auth.database.User import fr.dcproject.component.auth.database.User
import fr.dcproject.component.auth.database.UserCreator import fr.dcproject.component.auth.database.UserCreator
import fr.dcproject.component.auth.database.UserForCreate import fr.dcproject.component.auth.database.UserForCreate
@@ -95,10 +96,10 @@ open class CitizenRefWithUser(
open class CitizenRef( open class CitizenRef(
id: UUID = UUID.randomUUID() id: UUID = UUID.randomUUID()
) : Entity(id), ) : TargetRef(id),
CitizenI CitizenI
interface CitizenI : EntityI { interface CitizenI : EntityI, TargetI {
data class Name( data class Name(
override val firstName: String, override val firstName: String,
override val lastName: String, override val lastName: String,

View File

@@ -1,11 +1,13 @@
package fr.dcproject.component.follow package fr.dcproject.component.follow
import fr.dcproject.component.follow.database.FollowArticleRepository import fr.dcproject.component.follow.database.FollowArticleRepository
import fr.dcproject.component.follow.database.FollowCitizenRepository
import fr.dcproject.component.follow.database.FollowConstitutionRepository import fr.dcproject.component.follow.database.FollowConstitutionRepository
import org.koin.dsl.module import org.koin.dsl.module
val followKoinModule = module { val followKoinModule = module {
single { FollowArticleRepository(get()) } single { FollowArticleRepository(get()) }
single { FollowConstitutionRepository(get()) } single { FollowConstitutionRepository(get()) }
single { FollowCitizenRepository(get()) }
single { FollowAccessControl() } single { FollowAccessControl() }
} }

View File

@@ -4,7 +4,9 @@ import fr.dcproject.common.entity.Entity
import fr.dcproject.common.entity.TargetRef import fr.dcproject.common.entity.TargetRef
import fr.dcproject.component.article.database.ArticleForView import fr.dcproject.component.article.database.ArticleForView
import fr.dcproject.component.article.database.ArticleRef import fr.dcproject.component.article.database.ArticleRef
import fr.dcproject.component.citizen.database.Citizen
import fr.dcproject.component.citizen.database.CitizenI import fr.dcproject.component.citizen.database.CitizenI
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.constitution.database.ConstitutionForView import fr.dcproject.component.constitution.database.ConstitutionForView
import fr.dcproject.component.constitution.database.ConstitutionRef import fr.dcproject.component.constitution.database.ConstitutionRef
import fr.postgresjson.connexion.Paginated import fr.postgresjson.connexion.Paginated
@@ -72,21 +74,24 @@ sealed class FollowRepository<IN : TargetRef, OUT : TargetRef>(override var requ
target: Entity, target: Entity,
bulkSize: Int = 300 bulkSize: Int = 300
): Flow<FollowForView<IN>> = flow { ): Flow<FollowForView<IN>> = flow {
var nextPage = 1 var lastId: UUID? = null
do { while (true) {
val paginate = findFollowsByTarget(target, nextPage, bulkSize) val result = findFollowsByTarget(target, lastId, bulkSize)
paginate.result.forEach { if (result.count() == 0) {
break
}
result.forEach {
emit(it) emit(it)
} }
nextPage = paginate.currentPage + 1 lastId = result.last().id
} while (!paginate.isLastPage()) }
} }
abstract fun findFollowsByTarget( abstract fun findFollowsByTarget(
target: Entity, target: Entity,
page: Int = 1, lastId: UUID?,
limit: Int = 300 limit: Int = 300
): Paginated<FollowForView<IN>> ): List<FollowForView<IN>>
} }
class FollowArticleRepository(requester: Requester) : FollowRepository<ArticleRef, ArticleForView>(requester) { class FollowArticleRepository(requester: Requester) : FollowRepository<ArticleRef, ArticleForView>(requester) {
@@ -107,14 +112,14 @@ class FollowArticleRepository(requester: Requester) : FollowRepository<ArticleRe
override fun findFollowsByTarget( override fun findFollowsByTarget(
target: Entity, target: Entity,
page: Int, lastId: UUID?,
limit: Int limit: Int
): Paginated<FollowForView<ArticleRef>> { ): List<FollowForView<ArticleRef>> {
return requester return requester
.getFunction("find_follows_article_by_target") .getFunction("find_follows_article_by_target")
.select( .select(
page, "start_id" to lastId,
limit, "limit" to limit,
"target_id" to target.id "target_id" to target.id
) )
} }
@@ -138,9 +143,34 @@ class FollowConstitutionRepository(requester: Requester) : FollowRepository<Cons
override fun findFollowsByTarget( override fun findFollowsByTarget(
target: Entity, target: Entity,
page: Int, lastId: UUID?,
limit: Int limit: Int
): Paginated<FollowForView<ConstitutionRef>> { ): List<FollowForView<ConstitutionRef>> {
TODO("Not yet implemented")
}
}
class FollowCitizenRepository(requester: Requester) : FollowRepository<CitizenRef, Citizen>(requester) {
override fun findByCitizen(
citizenId: UUID,
page: Int,
limit: Int
): Paginated<FollowForView<Citizen>> {
return requester.run {
getFunction("find_follows_citizen_by_citizen")
.select(
page,
limit,
"created_by_id" to citizenId
)
}
}
override fun findFollowsByTarget(
target: Entity,
lastId: UUID?,
limit: Int
): List<FollowForView<CitizenRef>> {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
} }

View File

@@ -7,6 +7,7 @@ import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.follow.FollowAccessControl import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.database.FollowArticleRepository import fr.dcproject.component.follow.database.FollowArticleRepository
import fr.dcproject.component.follow.routes.citizen.toOutput
import io.ktor.application.call import io.ktor.application.call
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.locations.KtorExperimentalLocationsAPI import io.ktor.locations.KtorExperimentalLocationsAPI
@@ -14,7 +15,6 @@ import io.ktor.locations.Location
import io.ktor.locations.get import io.ktor.locations.get
import io.ktor.response.respond import io.ktor.response.respond
import io.ktor.routing.Route import io.ktor.routing.Route
import org.joda.time.DateTime
import java.util.UUID import java.util.UUID
@KtorExperimentalLocationsAPI @KtorExperimentalLocationsAPI
@@ -30,19 +30,7 @@ object GetFollowArticle {
ac.assert { canView(follow, citizenOrNull) } ac.assert { canView(follow, citizenOrNull) }
call.respond( call.respond(
HttpStatusCode.OK, HttpStatusCode.OK,
follow.let { f -> follow.toOutput()
object {
val id: UUID = f.id
val createdBy: Any = f.createdBy.toOutput()
val target: Any = f.target.let { t ->
object {
val id: UUID = t.id
val reference: String = f.target.reference
}
}
val createdAt: DateTime = f.createdAt
}
}
) )
} ?: call.respond(HttpStatusCode.NoContent) } ?: call.respond(HttpStatusCode.NoContent)
} }

View File

@@ -0,0 +1,36 @@
package fr.dcproject.component.follow.routes.citizen
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.database.FollowCitizenRepository
import fr.dcproject.component.follow.database.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 FollowCitizen {
@Location("/citizens/{citizen}/follows")
class CitizenFollowRequest(citizen: UUID) {
val citizen = CitizenRef(citizen)
}
fun Route.followCitizen(repo: FollowCitizenRepository, ac: FollowAccessControl) {
post<CitizenFollowRequest> {
mustBeAuth()
val follow = FollowForUpdate(target = it.citizen, createdBy = this.citizen)
ac.assert { canCreate(follow, citizenOrNull) }
repo.follow(follow)
call.respond(HttpStatusCode.Created)
}
}
}

View File

@@ -0,0 +1,37 @@
package fr.dcproject.component.follow.routes.citizen
import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.database.FollowCitizenRepository
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 GetFollowCitizen {
@Location("/citizens/{citizen}/follows")
class CitizenFollowRequest(citizen: UUID) {
val citizen = CitizenRef(citizen)
}
fun Route.getFollowCitizen(repo: FollowCitizenRepository, ac: FollowAccessControl) {
get<CitizenFollowRequest> {
repo.findFollow(citizen, it.citizen)?.let { follow ->
ac.assert { canView(follow, citizenOrNull) }
call.respond(
HttpStatusCode.OK,
follow.toOutput()
)
} ?: call.respond(HttpStatusCode.NoContent)
}
}
}

View File

@@ -0,0 +1,50 @@
package fr.dcproject.component.follow.routes.citizen
import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.database.FollowCitizenRepository
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 org.joda.time.DateTime
import java.util.UUID
@KtorExperimentalLocationsAPI
object GetMyFollowsCitizen {
@Location("/citizens/{citizen}/follows/citizens")
class CitizenFollowCitizenRequest(citizen: UUID) {
val citizen = CitizenRef(citizen)
}
fun Route.getMyFollowsCitizen(repo: FollowCitizenRepository, ac: FollowAccessControl) {
get<CitizenFollowCitizenRequest> {
mustBeAuth()
val follows = repo.findByCitizen(it.citizen)
ac.assert { canView(follows.result, citizenOrNull) }
call.respond(
HttpStatusCode.OK,
follows.toOutput { f ->
object {
val id: UUID = f.id
val createdBy: Any = f.createdBy.toOutput()
val target: Any = f.target.let { t ->
object {
val id: UUID = t.id
val reference: String = f.target.reference
}
}
val createdAt: DateTime = f.createdAt
}
}
)
}
}
}

View File

@@ -0,0 +1,36 @@
package fr.dcproject.component.follow.routes.citizen
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.auth.mustBeAuth
import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.database.FollowCitizenRepository
import fr.dcproject.component.follow.database.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 UnfollowCitizen {
@Location("/citizens/{citizen}/follows")
class CitizenFollowRequest(citizen: UUID) {
val citizen = CitizenRef(citizen)
}
fun Route.unfollowCitizen(repo: FollowCitizenRepository, ac: FollowAccessControl) {
delete<CitizenFollowRequest> {
mustBeAuth()
val follow = FollowForUpdate(target = it.citizen, createdBy = this.citizen)
ac.assert { canDelete(follow, citizenOrNull) }
repo.unfollow(follow)
call.respond(HttpStatusCode.NoContent)
}
}
}

View File

@@ -0,0 +1,20 @@
package fr.dcproject.component.follow.routes.citizen
import fr.dcproject.component.follow.routes.citizen.FollowCitizen.followCitizen
import fr.dcproject.component.follow.routes.citizen.GetFollowCitizen.getFollowCitizen
import fr.dcproject.component.follow.routes.citizen.GetMyFollowsCitizen.getMyFollowsCitizen
import fr.dcproject.component.follow.routes.citizen.UnfollowCitizen.unfollowCitizen
import io.ktor.auth.authenticate
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.routing.Routing
import org.koin.ktor.ext.get
@KtorExperimentalLocationsAPI
fun Routing.installFollowCitizenRoutes() {
authenticate(optional = true) {
followCitizen(get(), get())
unfollowCitizen(get(), get())
getFollowCitizen(get(), get())
getMyFollowsCitizen(get(), get())
}
}

View File

@@ -0,0 +1,20 @@
package fr.dcproject.component.follow.routes.citizen
import fr.dcproject.common.response.toOutput
import fr.dcproject.component.follow.database.FollowForView
import org.joda.time.DateTime
import java.util.UUID
fun FollowForView<*>.toOutput(): Any = this.let { f ->
object {
val id: UUID = f.id
val createdBy: Any = f.createdBy.toOutput()
val target: Any = f.target.let { t ->
object {
val id: UUID = t.id
val reference: String = f.target.reference
}
}
val createdAt: DateTime = f.createdAt
}
}

View File

@@ -7,6 +7,7 @@ import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.constitution.database.ConstitutionRef import fr.dcproject.component.constitution.database.ConstitutionRef
import fr.dcproject.component.follow.FollowAccessControl import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.database.FollowConstitutionRepository import fr.dcproject.component.follow.database.FollowConstitutionRepository
import fr.dcproject.component.follow.routes.citizen.toOutput
import io.ktor.application.call import io.ktor.application.call
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.locations.KtorExperimentalLocationsAPI import io.ktor.locations.KtorExperimentalLocationsAPI
@@ -14,7 +15,6 @@ import io.ktor.locations.Location
import io.ktor.locations.get import io.ktor.locations.get
import io.ktor.response.respond import io.ktor.response.respond
import io.ktor.routing.Route import io.ktor.routing.Route
import org.joda.time.DateTime
import java.util.UUID import java.util.UUID
@KtorExperimentalLocationsAPI @KtorExperimentalLocationsAPI
@@ -30,19 +30,7 @@ object GetFollowConstitution {
ac.assert { canView(follow, citizenOrNull) } ac.assert { canView(follow, citizenOrNull) }
call.respond( call.respond(
HttpStatusCode.OK, HttpStatusCode.OK,
follow.let { f -> follow.toOutput()
object {
val id: UUID = f.id
val createdBy: Any = f.createdBy.toOutput()
val target: Any = f.target.let { t ->
object {
val id: UUID = t.id
val reference: String = f.target.reference
}
}
val createdAt: DateTime = f.createdAt
}
}
) )
} ?: call.respond(HttpStatusCode.NotFound) } ?: call.respond(HttpStatusCode.NotFound)
} }

View File

@@ -1,112 +0,0 @@
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.follow.database.FollowArticleRepository
import fr.dcproject.component.follow.database.FollowConstitutionRepository
import fr.dcproject.component.follow.database.FollowForView
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(rawMessage)
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: FollowForView<out TargetRef>
)
}

View File

@@ -0,0 +1,67 @@
package fr.dcproject.component.notification
import com.rabbitmq.client.BuiltinExchangeType.DIRECT
import com.rabbitmq.client.ConnectionFactory
import com.rabbitmq.client.DefaultConsumer
import fr.dcproject.common.entity.Entity
import fr.dcproject.common.entity.TargetRef
import fr.dcproject.common.utils.consumeQueue
import fr.dcproject.component.follow.database.FollowArticleRepository
import fr.dcproject.component.follow.database.FollowCitizenRepository
import fr.dcproject.component.follow.database.FollowConstitutionRepository
import fr.dcproject.component.follow.database.FollowForView
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.runBlocking
abstract class NotificationConsumerAbstract(
private val rabbitFactory: ConnectionFactory,
private val followConstitutionRepo: FollowConstitutionRepository,
private val followArticleRepo: FollowArticleRepository,
private val followCitizenRepo: FollowCitizenRepository,
) {
private val rabbitConnection = rabbitFactory.newConnection()
private val rabbitChannel = rabbitConnection.createChannel()
fun close() {
rabbitChannel.close()
rabbitConnection.close()
}
fun declareQueue(queueName: String, exchangeName: String) {
rabbitFactory.newConnection().use { connection ->
connection.createChannel().use { channel ->
channel.queueDeclare(queueName, true, false, false, null)
channel.exchangeDeclare(exchangeName, DIRECT, true)
channel.queueBind(queueName, exchangeName, "")
}
}
}
protected fun consumeQueue(queueName: String, callback: DefaultConsumer.(DecodedMessage<*>) -> Unit) =
rabbitChannel.consumeQueue(queueName) { body ->
runBlocking {
followersFromMessage(body) {
callback(it)
}
}
}
protected suspend fun followersFromMessage(body: ByteArray, action: suspend (DecodedMessage<*>) -> Unit) {
val rawMessage: String = body.toString(Charsets.UTF_8)
val notification: EntityNotificationMessage<*> = NotificationMessage.fromString(rawMessage)
val follows = when (notification.type) {
"article" -> followArticleRepo.findFollowsByTarget(notification.target)
"constitution" -> followConstitutionRepo.findFollowsByTarget(notification.target)
"citizen" -> followCitizenRepo.findFollowsByTarget(notification.target)
else -> error("event '${notification.type}' not implemented")
}
follows.collect { action(DecodedMessage(notification, rawMessage, it)) }
}
protected class DecodedMessage <E : Entity> (
val event: EntityNotificationMessage<E>,
val rawMessage: String,
val follow: FollowForView<out TargetRef>
)
}

View File

@@ -1,70 +0,0 @@
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.EntityI
import fr.dcproject.common.entity.TargetRef
import fr.dcproject.component.article.database.ArticleRepository
import fr.dcproject.component.article.database.ArticleWithTitleI
import fr.dcproject.component.citizen.database.CitizenCreatorI
import fr.dcproject.component.citizen.database.CitizenRepository
import fr.dcproject.component.follow.database.FollowForView
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: FollowForView<out TargetRef>) {
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: CitizenCreatorI, target: EntityI): 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: CitizenCreatorI, target: EntityI): 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")
}

View File

@@ -16,9 +16,9 @@ import java.util.concurrent.atomic.AtomicInteger
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true)
@JsonSubTypes( @JsonSubTypes(
JsonSubTypes.Type(value = ArticleUpdateNotification::class, name = "article") JsonSubTypes.Type(value = ArticleUpdateNotificationMessage::class, name = "article")
) )
open class Notification( open class NotificationMessage(
val type: String, val type: String,
val createdAt: DateTime = DateTime.now() val createdAt: DateTime = DateTime.now()
) { ) {
@@ -48,16 +48,16 @@ open class Notification(
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
} }
inline fun <reified T : Notification> fromString(raw: String): T = mapper.readValue(raw) inline fun <reified T : NotificationMessage> fromString(raw: String): T = mapper.readValue(raw)
} }
} }
open class EntityNotification( open class EntityNotificationMessage <E : Entity> (
val target: Entity, val target: E,
type: String, type: String,
val action: String val action: String
) : Notification(type) ) : NotificationMessage(type)
class ArticleUpdateNotification( class ArticleUpdateNotificationMessage(
target: ArticleForView target: ArticleForView
) : EntityNotification(target, "article", "update") ) : EntityNotificationMessage<ArticleForView>(target, "article", "update")

View File

@@ -7,12 +7,15 @@ import kotlinx.coroutines.coroutineScope
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
class Publisher( class NotificationPublisherAsync(
private val factory: ConnectionFactory, private val factory: ConnectionFactory,
private val logger: Logger = LoggerFactory.getLogger(Publisher::class.qualifiedName), private val logger: Logger = LoggerFactory.getLogger(NotificationPublisherAsync::class.qualifiedName),
private val exchangeName: String, private val exchangeName: String,
) { ) {
suspend fun <T : EntityNotification> publish(it: T): Deferred<Unit> = coroutineScope { /**
* Publish a new notification message to RabbitMQ
*/
suspend fun <T : EntityNotificationMessage<*>> publishAsync(it: T): Deferred<Unit> = coroutineScope {
async { async {
factory.newConnection().use { connection -> factory.newConnection().use { connection ->
connection.createChannel().use { channel -> connection.createChannel().use { channel ->

View File

@@ -0,0 +1,33 @@
package fr.dcproject.component.notification.email
import com.rabbitmq.client.ConnectionFactory
import fr.dcproject.component.follow.database.FollowArticleRepository
import fr.dcproject.component.follow.database.FollowCitizenRepository
import fr.dcproject.component.follow.database.FollowConstitutionRepository
import fr.dcproject.component.notification.NotificationConsumerAbstract
import org.slf4j.Logger
import org.slf4j.LoggerFactory
class NotificationEmailConsumer(
rabbitFactory: ConnectionFactory,
followConstitutionRepo: FollowConstitutionRepository,
followArticleRepo: FollowArticleRepository,
followCitizenRepo: FollowCitizenRepository,
private val notificationEmailSender: NotificationEmailSender,
private val exchangeName: String,
) : NotificationConsumerAbstract(rabbitFactory, followConstitutionRepo, followArticleRepo, followCitizenRepo) {
private val logger: Logger = LoggerFactory.getLogger(NotificationEmailConsumer::class.qualifiedName)
fun start() {
/* Config Rabbit */
declareQueue(QUEUE_NAME, exchangeName)
consumeQueue(QUEUE_NAME) { message ->
notificationEmailSender.sendEmail(message.follow)
logger.debug("EmailSend to: ${message.follow.createdBy.id}")
}
}
companion object {
private const val QUEUE_NAME = "email"
}
}

View File

@@ -0,0 +1,70 @@
package fr.dcproject.component.notification.email
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.database.ArticleForView
import fr.dcproject.component.article.database.ArticleRepository
import fr.dcproject.component.citizen.database.Citizen
import fr.dcproject.component.citizen.database.CitizenRepository
import fr.dcproject.component.follow.database.FollowForView
import fr.dcproject.component.notification.email.content.ArticleNotificationEmailContent
import fr.dcproject.component.notification.email.content.CitizenNotificationEmailContent
import java.util.UUID
/**
* Send notification email on the follower
*/
class NotificationEmailSender(
private val mailer: Mailer,
private val domain: String,
private val citizenRepo: CitizenRepository,
private val articleRepo: ArticleRepository
) {
/**
* Send the Notification Email to the follower user
*/
fun sendEmail(follow: FollowForView<out TargetRef>) {
val citizen = citizenRepo.findById(follow.createdBy.id) ?: noCitizen(follow.createdBy.id)
/**
* Find the complete target entity by its ID according to its reference
*/
val target = when (follow.target.reference) {
"article" -> articleRepo.findById(follow.target.id) ?: noTarget(follow.target.id)
"citizen" -> citizenRepo.findById(follow.target.id) ?: noTarget(follow.target.id)
else -> noTarget(follow.target.id)
}
/**
* Find content of the email according to the target type
*/
val content = when (target) {
is ArticleForView -> ArticleNotificationEmailContent(citizen, target, domain)
is Citizen -> CitizenNotificationEmailContent(citizen, target, domain)
else -> noTargetTypeImplementation(follow.target.reference)
}
/* Send email */
mailer.sendEmail {
Mail(
Email("notification@$domain"),
content.subject,
Email(citizen.email),
Content("text/plain", content.content)
).apply {
addContent(Content("text/html", content.contentHtml))
}
}
}
class NoCitizen(message: String) : Exception(message)
class NoTarget(message: String) : Exception(message)
class NoTargetTypeImplement(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")
private fun noTargetTypeImplementation(type: String): Nothing = throw NoTargetTypeImplement("No Target type implemented: $type")
}

View File

@@ -0,0 +1,29 @@
package fr.dcproject.component.notification.email.content
import fr.dcproject.component.article.database.ArticleWithTitleI
import fr.dcproject.component.citizen.database.Citizen
class ArticleNotificationEmailContent(
private val citizen: Citizen,
private val target: ArticleWithTitleI,
private val domain: String,
) : NotificationEmailContent {
override val subject: String
get() = """New version for article "${target.title}""""
override val contentHtml
get() = run {
"""
Hello ${citizen.name.getFullName()},<br/>
The article "${target.title}" was updated, check it <a href="http://$domain/articles/${target.id}">here</a>
""".trimIndent()
}
override val content
get() = run {
"""
Hello ${citizen.name.getFullName()},
The article "${target.title}" was updated, check it here: http://$domain/articles/${target.id}
""".trimIndent()
}
}

View File

@@ -0,0 +1,28 @@
package fr.dcproject.component.notification.email.content
import fr.dcproject.component.citizen.database.Citizen
class CitizenNotificationEmailContent(
private val citizen: Citizen,
private val target: Citizen,
private val domain: String,
) : NotificationEmailContent {
override val subject: String
get() = """New activity for the citizen "${target.name}""""
override val contentHtml
get() = run {
"""
Hello ${citizen.name.getFullName()},
The citizen "${target.name}" was new activity, check it here: <a href="http://$domain/citizens/${target.id}">here</a>
""".trimIndent()
}
override val content
get() = run {
"""
Hello ${citizen.name.getFullName()},
The citizen "${target.name}" was new activity, check it here: http://$domain/citizens/${target.id}
""".trimIndent()
}
}

View File

@@ -0,0 +1,7 @@
package fr.dcproject.component.notification.email.content
interface NotificationEmailContent {
val subject: String
val content: String
val contentHtml: String
}

View File

@@ -0,0 +1,41 @@
package fr.dcproject.component.notification.push
import com.rabbitmq.client.ConnectionFactory
import fr.dcproject.component.follow.database.FollowArticleRepository
import fr.dcproject.component.follow.database.FollowCitizenRepository
import fr.dcproject.component.follow.database.FollowConstitutionRepository
import fr.dcproject.component.notification.NotificationConsumerAbstract
import io.lettuce.core.RedisClient
import io.lettuce.core.api.async.RedisAsyncCommands
import org.slf4j.Logger
import org.slf4j.LoggerFactory
class NotificationPushConsumer(
rabbitFactory: ConnectionFactory,
followConstitutionRepo: FollowConstitutionRepository,
followArticleRepo: FollowArticleRepository,
followCitizenRepo: FollowCitizenRepository,
redisClient: RedisClient,
private val exchangeName: String,
) : NotificationConsumerAbstract(rabbitFactory, followConstitutionRepo, followArticleRepo, followCitizenRepo) {
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 logger: Logger = LoggerFactory.getLogger(NotificationPushConsumer::class.qualifiedName)
fun start() {
/* Config Rabbit */
declareQueue(QUEUE_NAME, exchangeName)
consumeQueue(QUEUE_NAME) { message ->
redis.zadd(
"notification:${message.follow.createdBy.id}",
message.event.id,
message.rawMessage
)
logger.debug("Notification was transferred to the redis (follower: ${message.follow.createdBy.id})")
}
}
companion object {
private const val QUEUE_NAME = "push"
}
}

View File

@@ -1,8 +1,9 @@
package fr.dcproject.component.notification package fr.dcproject.component.notification.push
import com.fasterxml.jackson.core.JsonProcessingException import com.fasterxml.jackson.core.JsonProcessingException
import fr.dcproject.component.auth.citizen import fr.dcproject.component.auth.citizen
import fr.dcproject.component.citizen.database.CitizenI import fr.dcproject.component.citizen.database.CitizenI
import fr.dcproject.component.notification.NotificationMessage
import io.ktor.http.cio.websocket.Frame import io.ktor.http.cio.websocket.Frame
import io.ktor.http.cio.websocket.Frame.Text import io.ktor.http.cio.websocket.Frame.Text
import io.ktor.http.cio.websocket.readText import io.ktor.http.cio.websocket.readText
@@ -28,31 +29,42 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
class NotificationsPush( /**
* Listen a custom flow to mark as read a message.
*
* And listen the redis subscription flow and call a callback when a new message arrives
*/
class NotificationPushListener(
private val redis: RedisAsyncCommands<String, String>, private val redis: RedisAsyncCommands<String, String>,
private val redisConnectionPubSub: StatefulRedisPubSubConnection<String, String>, private val redisConnectionPubSub: StatefulRedisPubSubConnection<String, String>,
citizen: CitizenI, citizen: CitizenI,
incoming: Flow<Notification>, incoming: Flow<NotificationMessage>,
onReceive: suspend (Notification) -> Unit, onReceive: suspend (NotificationMessage) -> Unit,
) { ) {
class Builder(val redisClient: RedisClient) { class Builder(redisClient: RedisClient) {
private val redisConnection = redisClient.connect() ?: error("Unable to connect to redis") private val redisConnection = redisClient.connect()
private val redisConnectionPubSub = redisClient.connectPubSub() ?: error("Unable to connect to redis PubSub") private val redisConnectionPubSub = redisClient.connectPubSub()
private val redis: RedisAsyncCommands<String, String> = redisConnection.async() ?: error("Unable to connect to redis Async") private val redis: RedisAsyncCommands<String, String> = redisConnection.async()
/**
* Build Listener with citizen, incoming flow and set an outgoing callback
*/
fun build( fun build(
citizen: CitizenI, citizen: CitizenI,
incoming: Flow<Notification>, incoming: Flow<NotificationMessage>,
onReceive: suspend (Notification) -> Unit, onReceive: suspend (NotificationMessage) -> Unit,
): NotificationsPush = NotificationsPush(redis, redisConnectionPubSub, citizen, incoming, onReceive) ): NotificationPushListener = NotificationPushListener(redis, redisConnectionPubSub, citizen, incoming, onReceive)
/**
* Build NotificationPush with only a WebSocket session
*/
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
fun build(ws: DefaultWebSocketServerSession): NotificationsPush { fun build(ws: DefaultWebSocketServerSession): NotificationPushListener {
/* Convert channel of string from websocket, to a flow of Notification object */ /* Convert channel of string from websocket, to a flow of Notification object */
val incomingFlow: Flow<Notification> = ws.incoming.consumeAsFlow() val incomingFlow: Flow<NotificationMessage> = ws.incoming.consumeAsFlow()
.mapNotNull<Frame, Text> { it as? Frame.Text } .mapNotNull<Frame, Text> { it as? Text }
.map { it.readText() } .map { it.readText() }
.map { Notification.fromString(it) } .map { NotificationMessage.fromString(it) }
return build(ws.call.citizen, incomingFlow) { return build(ws.call.citizen, incomingFlow) {
ws.outgoing.send(Text(it.toString())) ws.outgoing.send(Text(it.toString()))
@@ -62,30 +74,42 @@ class NotificationsPush(
} }
} }
/**
* The key of the SortedSet in Redis which contains all the messages of a user
*/
private val key = "notification:${citizen.id}" private val key = "notification:${citizen.id}"
private var score: Double = 0.0 /**
* The last score (a kind of sorted ids) of message
*/
private var lastScore: Double = 0.0
/**
* Configure the listener to listen all new notifications
*/
private val listener = object : RedisPubSubAdapter<String, String>() { private val listener = object : RedisPubSubAdapter<String, String>() {
/* On new key publish */ /* On new key publish */
override fun message(pattern: String?, channel: String?, message: String?) { override fun message(pattern: String?, channel: String?, message: String?) {
runBlocking { runBlocking {
getNotifications().collect { getNewUnreadNotifications().collect {
onReceive(it) onReceive(it)
} }
} }
} }
} }
/**
* Init the listener and the callback
*/
init { init {
/* Mark as read all incoming notifications */ /* Mark as read all incoming notifications */
GlobalScope.launch { GlobalScope.launch {
incoming.collect { incoming.collect {
markAsRead(it) it.markAsRead()
} }
} }
/* Get old notification and sent it to websocket */ /* Get old notification and sent it to websocket */
runBlocking { runBlocking {
getNotifications().collect { getNewUnreadNotifications().collect {
onReceive(it) onReceive(it)
} }
} }
@@ -95,38 +119,55 @@ class NotificationsPush(
addListener(listener) addListener(listener)
/* Register to the events */ /* Register to the events */
async()?.psubscribe("__key*__:$key") ?: error("Unable to subscribe to redis events") async()?.psubscribe("__key*__:$key")
} }
} }
/**
* Close the redis subscription
*/
fun close() { fun close() {
redisConnectionPubSub.removeListener(listener) redisConnectionPubSub.removeListener(listener)
} }
/* Return flow with all new notifications */ /**
private fun getNotifications() = flow<Notification> { * Get All new notification from redis and
* Return flow with notifications
*
* On start, on the first call, this method return all unread notification of the user
*
* Internally this method return all messages that greater of the lastScore,
* then define the lastScore with the score of the last message.
*/
private fun getNewUnreadNotifications() = flow<NotificationMessage> {
redis redis
.zrangebyscoreWithScores( .zrangebyscoreWithScores(
key, key,
Range.from( Range.from(
Boundary.excluding(score), Boundary.excluding(lastScore),
Boundary.including(Double.POSITIVE_INFINITY) Boundary.including(Double.POSITIVE_INFINITY)
), ),
Limit.from(100) Limit.from(100)
) )
.get().forEach { .get().forEach {
emit(Notification.fromString(it.value)) /* Build message object from raw string and return it */
if (it.score > score) score = it.score emit(NotificationMessage.fromString(it.value))
if (it.score > lastScore) lastScore = it.score
} }
} }
private suspend fun markAsRead(notificationMessage: Notification) = coroutineScope { /**
* Mark one notification as read.
*
* Internally, this method remove the message of the SortedSet in redis
*/
private suspend fun NotificationMessage.markAsRead() = coroutineScope {
try { try {
redis.zremrangebyscore( redis.zremrangebyscore(
key, key,
Range.from( Range.from(
Boundary.including(notificationMessage.id), Boundary.including(id),
Boundary.including(notificationMessage.id) Boundary.including(id)
) )
) )
} catch (e: JsonProcessingException) { } catch (e: JsonProcessingException) {

View File

@@ -1,6 +1,6 @@
package fr.dcproject.component.notification.routes package fr.dcproject.component.notification.routes
import fr.dcproject.component.notification.NotificationsPush import fr.dcproject.component.notification.push.NotificationPushListener
import io.ktor.locations.KtorExperimentalLocationsAPI import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.routing.Route import io.ktor.routing.Route
import io.ktor.websocket.webSocket import io.ktor.websocket.webSocket
@@ -13,8 +13,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
*/ */
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
@KtorExperimentalLocationsAPI @KtorExperimentalLocationsAPI
fun Route.notificationArticle(pushBuilder: NotificationsPush.Builder) { fun Route.notificationArticle(pushListenerBuilder: NotificationPushListener.Builder) {
webSocket("/notifications") { webSocket("/notifications") {
pushBuilder.build(this) pushListenerBuilder.build(this)
} }
} }

View File

@@ -955,13 +955,105 @@ paths:
description: Return only http status 204 on success description: Return only http status 204 on success
401: 401:
$ref: '#/components/responses/401' $ref: '#/components/responses/401'
/citizens/{citizen}/follows:
parameters:
- $ref: '#/components/parameters/citizen'
get:
security:
- JWTAuth: [ ]
summary: Return Follows of citizen
tags:
- follow
- citizen
responses:
200:
description: Return follows
content:
application/json:
schema:
$ref: '#/components/schemas/FollowResponse'
404:
description: Citizen not exist
content:
application/json:
schema:
$ref: '#/components/schemas/404'
post:
security:
- JWTAuth: []
summary: Follow citizen
description: Follow a citizen to receive notifications of his activity
tags:
- follow
- citizen
responses:
201:
description: Return only http status 201 on success
401:
$ref: '#/components/responses/401'
404:
description: Citizen not exist
content:
application/json:
schema:
$ref: '#/components/schemas/404'
delete:
security:
- JWTAuth: [ ]
summary: Unfollow one citizen
tags:
- follow
- citizen
responses:
204:
description: Return only http status 204 on success
401:
$ref: '#/components/responses/401'
404:
description: Citizen not exist
content:
application/json:
schema:
$ref: '#/components/schemas/404'
/citizens/{citizen}/follows/citizens:
parameters:
- $ref: '#/components/parameters/citizen'
get:
security:
- JWTAuth: [ ]
summary: Return citizen Follow of citizen
tags:
- follow
- citizen
responses:
200:
description: Return citizen Follow of citizen
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/Paginated'
- type: object
properties:
result:
type: array
items:
$ref: '#/components/schemas/FollowResponse'
404:
description: Citizen not exist
content:
application/json:
schema:
$ref: '#/components/schemas/404'
/citizens/{citizen}/follows/articles: /citizens/{citizen}/follows/articles:
parameters: parameters:
- $ref: '#/components/parameters/citizen' - $ref: '#/components/parameters/citizen'
get: get:
security: security:
- JWTAuth: [ ] - JWTAuth: [ ]
summary: Return Follow or nothing if you not follow summary: Return article Follow of citizen
tags: tags:
- follow - follow
- article - article
@@ -1036,7 +1128,7 @@ paths:
- citizen - citizen
responses: responses:
200: 200:
description: Return your follows description: Return constitution Follow of citizen
content: content:
application/json: application/json:
schema: schema:

View File

@@ -1,20 +1,21 @@
create or replace function find_follows_article_by_target( create or replace function find_follows_article_by_target(
_target_id uuid, _target_id uuid,
"limit" int default 50, _limit int default 50,
"offset" int default 0, _start_id uuid default null,
out resource json, out resource json
out total int
) language plpgsql as ) language plpgsql as
$$ $$
declare declare
_version_id uuid = (select version_id from article where id = _target_id); _version_id uuid = (select version_id from article where id = _target_id);
_start_at timestamp default '2000-01-01 00:00:00'::timestamp;
_article_creator_id uuid = (select created_by_id from article where id = _target_id);
begin begin
select json_agg(t), ( if _start_id is not null then
select count(f.id) select created_at into _start_at from follow where id = _start_id;
from follow f end if;
join article a on f.target_id = a.id
where a.version_id = _version_id) select json_agg(t)
into resource, total into resource
from ( from (
select select
f.id, f.id,
@@ -22,11 +23,17 @@ begin
f.target_reference, f.target_reference,
json_build_object('id', f.target_id) as target, json_build_object('id', f.target_id) as target,
find_citizen_by_id_with_user(f.created_by_id) as created_by find_citizen_by_id_with_user(f.created_by_id) as created_by
from follow_article as f from follow as f
join article a on f.target_id = a.id left join article a on f.target_reference = 'article'::regclass and f.target_id = a.id
where a.version_id = _version_id where (
(f.target_reference = 'article'::regclass and a.version_id = _version_id)
or
(f.target_reference = 'citizen'::regclass and f.target_id = _article_creator_id)
)
and f.created_at >= _start_at
and (_start_id is null or f.id != _start_id)
order by f.created_at order by f.created_at
limit "limit" offset "offset" limit _limit
) as t; ) as t;
end end
$$; $$;

View File

@@ -0,0 +1,24 @@
create or replace function find_follows_citizen_by_citizen(
_created_by_id uuid,
"limit" int default 50,
"offset" int default 0,
out resource json,
out total int
) language plpgsql as
$$
begin
select json_agg(t), (select count(id) from follow)
into resource, total
from (
select
f.*,
find_citizen_by_id_with_user(f.target_id) as target,
find_citizen_by_id_with_user(f.created_by_id) as created_by
from follow as f
where created_by_id = _created_by_id
order by created_at desc,
f.created_at desc
limit "limit" offset "offset"
) as t;
end;
$$;

View File

@@ -10,10 +10,11 @@ import fr.dcproject.component.citizen.database.CitizenCreator
import fr.dcproject.component.citizen.database.CitizenI import fr.dcproject.component.citizen.database.CitizenI
import fr.dcproject.component.follow.database.FollowArticleRepository import fr.dcproject.component.follow.database.FollowArticleRepository
import fr.dcproject.component.follow.database.FollowForView import fr.dcproject.component.follow.database.FollowForView
import fr.dcproject.component.notification.ArticleUpdateNotification import fr.dcproject.component.notification.ArticleUpdateNotificationMessage
import fr.dcproject.component.notification.NotificationConsumer import fr.dcproject.component.notification.NotificationPublisherAsync
import fr.dcproject.component.notification.NotificationEmailSender import fr.dcproject.component.notification.email.NotificationEmailConsumer
import fr.dcproject.component.notification.Publisher import fr.dcproject.component.notification.email.NotificationEmailSender
import fr.dcproject.component.notification.push.NotificationPushConsumer
import io.ktor.locations.KtorExperimentalLocationsAPI import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.util.KtorExperimentalAPI import io.ktor.util.KtorExperimentalAPI
import io.lettuce.core.RedisClient import io.lettuce.core.RedisClient
@@ -65,7 +66,7 @@ class NotificationConsumerTest {
@KtorExperimentalAPI @KtorExperimentalAPI
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
@Test @Test
fun `can be send notification`() = runBlocking { fun `can be receive article update notification when follow article`() = runBlocking {
val config: Configuration = Configuration("application-test.conf") val config: Configuration = Configuration("application-test.conf")
/* Create mocks and spy's */ /* Create mocks and spy's */
val emailSender = mockk<NotificationEmailSender>() { val emailSender = mockk<NotificationEmailSender>() {
@@ -88,21 +89,30 @@ class NotificationConsumerTest {
} }
/* Config consumer */ /* Config consumer */
val consumer = NotificationConsumer( val emailConsumer = NotificationEmailConsumer(
rabbitFactory = rabbitFactory, rabbitFactory = rabbitFactory,
redisClient = redisClient,
followArticleRepo = followArticleRepo, followArticleRepo = followArticleRepo,
followConstitutionRepo = mockk(), followConstitutionRepo = mockk(), // TODO test followConstitution
followCitizenRepo = mockk(), // TODO test followCitizen
notificationEmailSender = emailSender, notificationEmailSender = emailSender,
exchangeName = "notification", exchangeName = "notification",
).apply { start() } ).apply { start() }
val pushConsumer = NotificationPushConsumer(
rabbitFactory = rabbitFactory,
followArticleRepo = followArticleRepo,
followConstitutionRepo = mockk(), // TODO test followConstitution
followCitizenRepo = mockk(), // TODO test followCitizen
redisClient = redisClient,
exchangeName = "notification",
).apply { start() }
/* Push message */ /* Push message */
Publisher( NotificationPublisherAsync(
factory = rabbitFactory, factory = rabbitFactory,
exchangeName = "notification", exchangeName = "notification",
).publish( ).publishAsync(
ArticleUpdateNotification( ArticleUpdateNotificationMessage(
ArticleForView( ArticleForView(
title = "MyTitle", title = "MyTitle",
content = "myContent", content = "myContent",
@@ -121,6 +131,7 @@ class NotificationConsumerTest {
verify(timeout = 2000) { emailSender.sendEmail(any()) } verify(timeout = 2000) { emailSender.sendEmail(any()) }
verify(timeout = 2000) { asyncCommand.zadd(any<String>(), any<Double>(), any<String>()) } verify(timeout = 2000) { asyncCommand.zadd(any<String>(), any<Double>(), any<String>()) }
consumer.close() emailConsumer.close()
pushConsumer.close()
} }
} }

View File

@@ -6,9 +6,9 @@ import fr.dcproject.component.article.database.ArticleForView
import fr.dcproject.component.auth.database.UserCreator import fr.dcproject.component.auth.database.UserCreator
import fr.dcproject.component.citizen.database.CitizenCreator import fr.dcproject.component.citizen.database.CitizenCreator
import fr.dcproject.component.citizen.database.CitizenI import fr.dcproject.component.citizen.database.CitizenI
import fr.dcproject.component.notification.ArticleUpdateNotification import fr.dcproject.component.notification.ArticleUpdateNotificationMessage
import fr.dcproject.component.notification.Notification import fr.dcproject.component.notification.NotificationMessage
import fr.dcproject.component.notification.NotificationsPush import fr.dcproject.component.notification.push.NotificationPushListener
import io.lettuce.core.RedisClient import io.lettuce.core.RedisClient
import io.mockk.every import io.mockk.every
import io.mockk.spyk import io.mockk.spyk
@@ -68,14 +68,14 @@ internal class NotificationsPushTest {
title = "Super Title", title = "Super Title",
) )
/* Init two notification, one called before subscription, and the other after */ /* Init two notification, one called before subscription, and the other after */
val notifBeforeSubscribe = ArticleUpdateNotification(article) val notifBeforeSubscribe = ArticleUpdateNotificationMessage(article)
runBlocking { runBlocking {
delay(100) delay(100)
} }
val notifAfterSubscribe = ArticleUpdateNotification(article) val notifAfterSubscribe = ArticleUpdateNotificationMessage(article)
/* init event for emulate incoming message from websocket */ /* init event for emulate incoming message from websocket */
val event = MutableSharedFlow<Notification>() val event = MutableSharedFlow<NotificationMessage>()
val incomingFlow = event.asSharedFlow() val incomingFlow = event.asSharedFlow()
spyk(object { var counter = 0 }).run { /* Counter for count the callback of notification */ spyk(object { var counter = 0 }).run { /* Counter for count the callback of notification */
@@ -90,7 +90,7 @@ internal class NotificationsPushTest {
} }
/* Init NotificationPush system, and set assertion in callback */ /* Init NotificationPush system, and set assertion in callback */
val notificationPush = NotificationsPush.Builder(redisClient).build(citizen, incomingFlow) { val notificationPush = NotificationPushListener.Builder(redisClient).build(citizen, incomingFlow) {
counter++ counter++
if (counter == 1) it.id `should be equal to` notifBeforeSubscribe.id if (counter == 1) it.id `should be equal to` notifBeforeSubscribe.id
else it.id `should be equal to` notifAfterSubscribe.id else it.id `should be equal to` notifAfterSubscribe.id

View File

@@ -0,0 +1,96 @@
package integration
import integration.steps.`when`.`When I send a DELETE request`
import integration.steps.`when`.`When I send a GET request`
import integration.steps.`when`.`When I send a POST request`
import integration.steps.given.`And follow citizen`
import integration.steps.given.`Given I have citizen`
import integration.steps.given.`authenticated as`
import integration.steps.given.`with no content`
import integration.steps.then.`And the response should be null`
import integration.steps.then.`And the response should contain`
import integration.steps.then.`And the response should not be null`
import integration.steps.then.`Then the response should be`
import integration.steps.then.and
import io.ktor.http.HttpStatusCode.Companion.Created
import io.ktor.http.HttpStatusCode.Companion.NoContent
import io.ktor.http.HttpStatusCode.Companion.OK
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Tags
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Tags(Tag("integration"), Tag("article"), Tag("follow"))
class `Follow citizen routes` : BaseTest() {
@Test
fun `I can follow citizen`() {
withIntegrationApplication {
/* Followed user */
`Given I have citizen`("John", "Glenn", id = "7e1580c5-05b7-4557-84f4-faac9f0a9441")
/* Current user */
`Given I have citizen`("Valentina", "Terechkova")
`When I send a POST request`("/citizens/7e1580c5-05b7-4557-84f4-faac9f0a9441/follows") {
`authenticated as`("Valentina", "Terechkova")
`with no content`()
} `Then the response should be` Created
}
}
@Test
fun `I can get my follow citizen`() {
withIntegrationApplication {
/* Followed user */
`Given I have citizen`("Jean-Loup", "Chrétien", id = "c2432b94-a509-4116-a8b6-9774bc963372")
/* Current user */
`Given I have citizen`("John", "Young", id = "6d41ce65-9df7-47e0-af46-8da4a909490b") {
`And follow citizen`("c2432b94-a509-4116-a8b6-9774bc963372")
}
/* Get my all follows */
`When I send a GET request`("/citizens/6d41ce65-9df7-47e0-af46-8da4a909490b/follows/citizens") {
`authenticated as`("John", "Young")
} `Then the response should be` OK and {
`And the response should not be null`()
`And the response should contain`("$.currentPage", 1)
`And the response should contain`("$.limit", 50)
}
}
}
@Test
fun `I can unfollow citizen`() {
withIntegrationApplication {
/* Followed user */
`Given I have citizen`("Bruce", "McCandless", id = "680c7af7-d2de-4249-bfcb-47007ef546fe")
/* Current user */
`Given I have citizen`("Jean-François", "Clervoy", id = "a12455ae-1047-43ff-826d-0d826dbe90f7") {
`And follow citizen`("680c7af7-d2de-4249-bfcb-47007ef546fe")
}
`When I send a DELETE request`("/citizens/680c7af7-d2de-4249-bfcb-47007ef546fe/follows") {
`authenticated as`("Jean-François", "Clervoy")
`with no content`()
} `Then the response should be` NoContent and {
`And the response should be null`()
}
}
}
@Test
fun `I can know if I follow an citizen`() {
withIntegrationApplication {
/* Followed user */
`Given I have citizen`("Eugene", "Cernan", id = "c755788f-7f48-4cde-8ff0-e75bcffdafc2")
/* Current user */
`Given I have citizen`("Buzz", "Aldrin", id = "39e2915a-e96f-43ea-babd-bd339d8bf197") {
`And follow citizen`("c755788f-7f48-4cde-8ff0-e75bcffdafc2")
}
`When I send a GET request`("/citizens/c755788f-7f48-4cde-8ff0-e75bcffdafc2/follows") {
`authenticated as`("Buzz", "Aldrin")
`with no content`()
} `Then the response should be` OK and {
`And the response should not be null`()
`And the response should contain`("$.target.id", "c755788f-7f48-4cde-8ff0-e75bcffdafc2")
}
}
}
}

View File

@@ -1,25 +1,20 @@
package integration package integration
import fr.dcproject.common.utils.toUUID
import fr.dcproject.component.article.database.ArticleForView
import fr.dcproject.component.auth.database.UserCreator
import fr.dcproject.component.citizen.database.CitizenCreator
import fr.dcproject.component.citizen.database.CitizenI.Name import fr.dcproject.component.citizen.database.CitizenI.Name
import fr.dcproject.component.notification.ArticleUpdateNotification import fr.dcproject.component.notification.ArticleUpdateNotificationMessage
import fr.dcproject.component.notification.Notification import fr.dcproject.component.notification.NotificationMessage
import fr.dcproject.component.notification.Publisher import integration.steps.given.`And follow citizen`
import integration.steps.given.`Given I have article update notification`
import integration.steps.given.`Given I have article` import integration.steps.given.`Given I have article`
import integration.steps.given.`Given I have citizen` import integration.steps.given.`Given I have citizen`
import integration.steps.given.`Given I have follow on article` import integration.steps.given.`Given I have follow on article`
import integration.steps.given.`authenticated in url as` import integration.steps.given.`authenticated in url as`
import io.ktor.http.cio.websocket.Frame import io.ktor.http.cio.websocket.Frame
import io.ktor.http.cio.websocket.readText import io.ktor.http.cio.websocket.readText
import kotlinx.coroutines.launch
import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Tags import org.junit.jupiter.api.Tags
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance
import org.koin.test.get
import kotlin.test.assertEquals import kotlin.test.assertEquals
@TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestInstance(TestInstance.Lifecycle.PER_CLASS)
@@ -31,26 +26,7 @@ class `Notification routes` : BaseTest() {
`Given I have citizen`("John", "Doe", id = "1a34191a-9cde-45ba-8ac1-230138a102d3") `Given I have citizen`("John", "Doe", id = "1a34191a-9cde-45ba-8ac1-230138a102d3")
`Given I have article`(id = "a06cbfb7-3094-4d64-aaa1-7486c0c292f4", createdBy = Name(firstName = "John", lastName = "Doe")) `Given I have article`(id = "a06cbfb7-3094-4d64-aaa1-7486c0c292f4", createdBy = Name(firstName = "John", lastName = "Doe"))
`Given I have follow on article`("John", "Doe", article = "a06cbfb7-3094-4d64-aaa1-7486c0c292f4") `Given I have follow on article`("John", "Doe", article = "a06cbfb7-3094-4d64-aaa1-7486c0c292f4")
val notification = ArticleUpdateNotification( `Given I have article update notification`("a06cbfb7-3094-4d64-aaa1-7486c0c292f4")
ArticleForView(
id = "a06cbfb7-3094-4d64-aaa1-7486c0c292f4".toUUID(),
title = "MyTitle",
content = "myContent",
description = "myDescription",
createdBy = CitizenCreator(
id = "1a34191a-9cde-45ba-8ac1-230138a102d3".toUUID(),
name = Name(firstName = "John", lastName = "Doe"),
email = "john-doe@plop.com",
user = UserCreator(username = "john-doe"),
)
)
)
val publisher = get<Publisher>()
launch {
publisher
.publish(notification)
.await()
}
Thread.sleep(1000) Thread.sleep(1000)
@@ -62,7 +38,41 @@ class `Notification routes` : BaseTest() {
) { incoming, outgoing -> ) { incoming, outgoing ->
incoming.receive().let { incoming.receive().let {
when (it) { when (it) {
is Frame.Text -> Notification.fromString<ArticleUpdateNotification>(it.readText()).let { notif -> is Frame.Text -> NotificationMessage.fromString<ArticleUpdateNotificationMessage>(it.readText()).let { notif ->
assertEquals(
"a06cbfb7-3094-4d64-aaa1-7486c0c292f4",
notif.target.id.toString()
)
outgoing.send(it)
}
else -> error(it.toString())
}
}
}
}
}
@Test
fun `I can receive article update notification when follow the creator`() {
withIntegrationApplication {
`Given I have citizen`("Thomas", "Pesquet", id = "1a34191a-9cde-45ba-8ac1-230138a102d3")
`Given I have article`(id = "a06cbfb7-3094-4d64-aaa1-7486c0c292f4", createdBy = Name(firstName = "Thomas", lastName = "Pesquet"))
`Given I have citizen`("Alan", "Bean") {
`And follow citizen`(Name("Thomas", "Pesquet"))
}
`Given I have article update notification`("a06cbfb7-3094-4d64-aaa1-7486c0c292f4")
Thread.sleep(1000)
handleWebSocketConversation(
"/notifications",
{
`authenticated in url as`("Alan", "Bean")
}
) { incoming, outgoing ->
incoming.receive().let {
when (it) {
is Frame.Text -> NotificationMessage.fromString<ArticleUpdateNotificationMessage>(it.readText()).let { notif ->
assertEquals( assertEquals(
"a06cbfb7-3094-4d64-aaa1-7486c0c292f4", "a06cbfb7-3094-4d64-aaa1-7486c0c292f4",
notif.target.id.toString() notif.target.id.toString()

View File

@@ -8,6 +8,7 @@ import fr.dcproject.component.citizen.database.CitizenRef
import fr.dcproject.component.citizen.database.CitizenRepository import fr.dcproject.component.citizen.database.CitizenRepository
import fr.dcproject.component.constitution.database.ConstitutionRef import fr.dcproject.component.constitution.database.ConstitutionRef
import fr.dcproject.component.follow.database.FollowArticleRepository import fr.dcproject.component.follow.database.FollowArticleRepository
import fr.dcproject.component.follow.database.FollowCitizenRepository
import fr.dcproject.component.follow.database.FollowConstitutionRepository import fr.dcproject.component.follow.database.FollowConstitutionRepository
import fr.dcproject.component.follow.database.FollowForUpdate import fr.dcproject.component.follow.database.FollowForUpdate
import io.ktor.server.testing.TestApplicationEngine import io.ktor.server.testing.TestApplicationEngine
@@ -24,6 +25,18 @@ fun Citizen.`And follow constitution`(
) { ) {
createFollow(this, ConstitutionRef(constitution.toUUID())) createFollow(this, ConstitutionRef(constitution.toUUID()))
} }
fun Citizen.`And follow citizen`(
citizen: String,
) {
createFollow(this, CitizenRef(citizen.toUUID()))
}
fun Citizen.`And follow citizen`(
name: CitizenI.Name,
) {
val citizenRepository: CitizenRepository by lazy { GlobalContext.get().get() }
val citizen = citizenRepository.findByName(name) ?: error("Citizen not exist")
createFollow(this, CitizenRef(citizen.id))
}
fun TestApplicationEngine.`Given I have follow on article`( fun TestApplicationEngine.`Given I have follow on article`(
firstName: String, firstName: String,
@@ -35,6 +48,17 @@ fun TestApplicationEngine.`Given I have follow on article`(
createFollow(citizen, ArticleRef(article.toUUID())) createFollow(citizen, ArticleRef(article.toUUID()))
} }
fun TestApplicationEngine.`Given I have follow on citizen`(
firstName: String,
lastName: String,
target: CitizenI.Name,
) {
val citizenRepository: CitizenRepository by lazy { GlobalContext.get().get() }
val citizen = citizenRepository.findByName(CitizenI.Name(firstName, lastName)) ?: error("Citizen not exist")
val targetCitizen = citizenRepository.findByName(target) ?: error("Citizen not exist")
createFollow(citizen, CitizenRef(targetCitizen.id))
}
fun TestApplicationEngine.`Given I have follow on constitution`( fun TestApplicationEngine.`Given I have follow on constitution`(
firstName: String, firstName: String,
lastName: String, lastName: String,
@@ -56,3 +80,9 @@ fun createFollow(citizen: CitizenRef, constitution: ConstitutionRef) {
val follow = FollowForUpdate(createdBy = citizen, target = constitution) val follow = FollowForUpdate(createdBy = citizen, target = constitution)
followConstitutionRepository.follow(follow) followConstitutionRepository.follow(follow)
} }
fun createFollow(createdBy: CitizenRef, target: CitizenRef) {
val followCitizenRepository: FollowCitizenRepository by lazy { GlobalContext.get().get() }
val follow = FollowForUpdate(createdBy = createdBy, target = target)
followCitizenRepository.follow(follow)
}

View File

@@ -0,0 +1,40 @@
package integration.steps.given
import fr.dcproject.common.utils.toUUID
import fr.dcproject.component.article.database.ArticleForView
import fr.dcproject.component.auth.database.UserCreator
import fr.dcproject.component.citizen.database.CitizenCreator
import fr.dcproject.component.citizen.database.CitizenI
import fr.dcproject.component.notification.ArticleUpdateNotificationMessage
import fr.dcproject.component.notification.NotificationPublisherAsync
import io.ktor.server.testing.TestApplicationEngine
import kotlinx.coroutines.launch
import org.koin.mp.KoinPlatformTools
import java.util.UUID
fun TestApplicationEngine.`Given I have article update notification`(
id: String = UUID.randomUUID().toString()
) {
val notification = ArticleUpdateNotificationMessage(
ArticleForView(
id = id.toUUID(),
title = "MyTitle",
content = "myContent",
description = "myDescription",
createdBy = CitizenCreator(
id = "1a34191a-9cde-45ba-8ac1-230138a102d3".toUUID(),
name = CitizenI.Name(firstName = "John", lastName = "Doe"),
email = "john-doe@plop.com",
user = UserCreator(username = "john-doe"),
)
)
)
launch {
KoinPlatformTools
.defaultContext()
.get()
.get<NotificationPublisherAsync>()
.publishAsync(notification)
.await()
}
}

View File

@@ -7,17 +7,19 @@ declare
_version_id1 uuid = uuid_generate_v4(); _version_id1 uuid = uuid_generate_v4();
first_article_id uuid := fixture_article(_citizen_id := _citizen_id, _version_id := _version_id1); first_article_id uuid := fixture_article(_citizen_id := _citizen_id, _version_id := _version_id1);
first_article_updated_id uuid; first_article_updated_id uuid;
_follow_count int = 0;
begin begin
perform follow('citizen'::regclass, _citizen_id, _citizen_id2); perform follow('citizen'::regclass, _citizen_id, _citizen_id2);
assert (select count(*) = 1 from follow), 'follow must be inserted'; assert (select count(*) = 1 from follow), 'follow must be inserted';
assert (select following = true from find_follow(_citizen_id, _citizen_id2, 'citizen')), 'find_follow must return the following'; assert (select following = true from find_follow(_citizen_id, _citizen_id2, 'citizen')), 'find_follow must return the following';
perform follow('citizen'::regclass, _citizen_id, _citizen_id2); perform follow('citizen'::regclass, _citizen_id, _citizen_id2);
assert (select count(*) = 1 from follow), 'follow must be inserted'; assert (select count(*) = 1 from follow), 're follow must be do nothing';
perform unfollow('citizen'::regclass, _citizen_id, _citizen_id2); perform unfollow('citizen'::regclass, _citizen_id, _citizen_id2);
assert (select count(*) = 0 from follow), 'follow must be deleted after unfollow'; assert (select count(*) = 0 from follow), 'follow must be deleted after unfollow';
perform follow('article'::regclass, first_article_id, _citizen_id); perform follow('article'::regclass, first_article_id, _citizen_id);
assert (select following = true from find_follow(first_article_id, _citizen_id, 'article')), 'find_follow must return the following'; assert (select following = true from find_follow(first_article_id, _citizen_id, 'article')), 'find_follow must return the following';
assert (select following = false from find_follow(first_article_id, _citizen_id2, 'article')), 'find_follow must not return the following if not followinf'; assert (select following = false from find_follow(first_article_id, _citizen_id2, 'article')), 'find_follow must not return the following if not followinf';
@@ -29,11 +31,17 @@ begin
assert (select following = true from find_follow(first_article_id, _citizen_id, 'article')), '(v1) find_follow must return the following'; assert (select following = true from find_follow(first_article_id, _citizen_id, 'article')), '(v1) find_follow must return the following';
assert (select following = true from find_follow(first_article_updated_id, _citizen_id, 'article')), '(v2) find_follow must return the following'; assert (select following = true from find_follow(first_article_updated_id, _citizen_id, 'article')), '(v2) find_follow must return the following';
assert (select f.total = 1 from find_follows_article_by_target(first_article_id) as f), 'find_follows_article_by_target must return 1 follow'; assert (select count(*) = 1 from follow), 'must be only 1 follow';
assert (select (f.resource#>>'{0, created_by, id}')::uuid = _citizen_id from find_follows_article_by_target(first_article_id) as f), 'find_follows_article_by_target must return follows with creator'; perform follow('citizen'::regclass, _citizen_id, _citizen_id2);
assert (select count(*) = 2 from follow), 'follow citizen must be inserted';
assert (select json_array_length(f.resource) = 2 from find_follows_article_by_target(first_article_id) as f), 'find_follows_article_by_target must return 2 follows';
assert (select (f.resource#>>'{0, created_by, id}')::uuid = _citizen_id from find_follows_article_by_target(first_article_id) as f), 'find_follows_article_by_target must return follows with creator';
assert (select (f.resource#>>'{1, created_by, id}')::uuid = _citizen_id2 from find_follows_article_by_target(first_article_id) as f), 'find_follows_article_by_target must return follows with creator';
_follow_count = (select count(*) from follow);
perform unfollow('article'::regclass, first_article_id, _citizen_id); perform unfollow('article'::regclass, first_article_id, _citizen_id);
assert (select count(*) = 0 from follow), 'follow must be deleted after unfollow, event if article is on other version'; assert (select count(*) = _follow_count-1 from follow), 'follow must be deleted after unfollow, event if article is on other version';
rollback; rollback;
raise notice 'follow test pass'; raise notice 'follow test pass';