diff --git a/build.gradle b/build.gradle index 5028589..f4bed17 100644 --- a/build.gradle +++ b/build.gradle @@ -1,21 +1,27 @@ buildscript { + ext.kotlin_version = '1.3.61' + repositories { mavenCentral() google() } + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" + } } -plugins { - id 'org.jetbrains.kotlin.jvm' version'1.3.50' -} +apply plugin: 'kotlin' subprojects { buildscript { repositories { + jcenter() mavenCentral() } dependencies { - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.50") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + classpath("org.jetbrains.kotlin:kotlin-serialization:$kotlin_version") classpath("com.github.jengelman.gradle.plugins:shadow:5.1.0") } } diff --git a/common/build.gradle b/common/build.gradle index a94322e..9ec9744 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -1,10 +1,14 @@ plugins { + id "idea" id "kotlin" id "application" } +apply plugin: 'kotlinx-serialization' dependencies { - api("org.jetbrains.kotlin:kotlin-stdlib:1.3.50") + api("org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version") + api("org.jetbrains.kotlin:kotlin-reflect:$kotlin_version") + api("org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.14.0") // API api("com.google.protobuf:protobuf-java:3.6.1") @@ -14,3 +18,9 @@ dependencies { // Storage api("com.couchbase.client:java-client:2.7.9") } + +compileKotlin { + kotlinOptions { + freeCompilerArgs += "-Xuse-experimental=kotlin.Experimental" + } +} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/Context.kt b/common/src/main/kotlin/de/hpi/cloud/common/Context.kt new file mode 100644 index 0000000..5da6714 --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/Context.kt @@ -0,0 +1,9 @@ +package de.hpi.cloud.common + +import de.hpi.cloud.common.entity.Id +import java.util.* + +data class Context( + val author: Id, + val languageRanges: List +) diff --git a/common/src/main/kotlin/de/hpi/cloud/common/Entity.kt b/common/src/main/kotlin/de/hpi/cloud/common/Entity.kt deleted file mode 100644 index 038ccd0..0000000 --- a/common/src/main/kotlin/de/hpi/cloud/common/Entity.kt +++ /dev/null @@ -1,17 +0,0 @@ -package de.hpi.cloud.common - -import de.hpi.cloud.common.utils.couchbase.buildJsonDocument -import de.hpi.cloud.common.utils.protobuf.timestampNow -import de.hpi.cloud.common.utils.protobuf.toDbMap - -abstract class Entity(val type: String, val version: Int) { - abstract val id: String - open val fetchedAt = timestampNow() - - // region JSON - abstract fun valueToMap(): Map - - open fun metaToMap(): Map = mapOf("fetchedAt" to fetchedAt.toDbMap()) - fun toJsonDocument() = buildJsonDocument(id, type, version, valueToMap(), metaToMap()) - // endregion -} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/Party.kt b/common/src/main/kotlin/de/hpi/cloud/common/Party.kt new file mode 100644 index 0000000..fb6cf96 --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/Party.kt @@ -0,0 +1,11 @@ +package de.hpi.cloud.common + +import de.hpi.cloud.common.entity.Entity +import kotlinx.serialization.Serializable + +@Serializable +data class Party( + val name: String +) : Entity() { + companion object : Entity.Companion("party") +} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/Persistable.kt b/common/src/main/kotlin/de/hpi/cloud/common/Persistable.kt new file mode 100644 index 0000000..8611dc6 --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/Persistable.kt @@ -0,0 +1,10 @@ +package de.hpi.cloud.common + +import com.google.protobuf.GeneratedMessageV3 + +abstract class Persistable

> { + interface ProtoSerializer

, Proto : GeneratedMessageV3> { + fun fromProto(proto: Proto, context: Context): P + fun toProto(persistable: P, context: Context): Proto + } +} diff --git a/service-common/src/main/kotlin/de/hpi/cloud/common/Service.kt b/common/src/main/kotlin/de/hpi/cloud/common/Service.kt similarity index 77% rename from service-common/src/main/kotlin/de/hpi/cloud/common/Service.kt rename to common/src/main/kotlin/de/hpi/cloud/common/Service.kt index 140f65c..4f88751 100644 --- a/service-common/src/main/kotlin/de/hpi/cloud/common/Service.kt +++ b/common/src/main/kotlin/de/hpi/cloud/common/Service.kt @@ -3,7 +3,9 @@ package de.hpi.cloud.common import com.couchbase.client.java.Bucket import com.couchbase.client.java.CouchbaseCluster import com.google.protobuf.GeneratedMessageV3 -import de.hpi.cloud.common.utils.couchbase.openCouchbase +import de.hpi.cloud.common.couchbase.openCouchbase +import de.hpi.cloud.common.entity.Id +import de.hpi.cloud.common.grpc.preferredLocales import de.hpi.cloud.common.utils.removeFirst import io.grpc.* @@ -17,9 +19,17 @@ class Service( const val PORT_DEFAULT = 50051 const val PORT_VARIABLE = "HPI_CLOUD_PORT" - private val requestMetadata = mutableListOf>() - fun metadataForRequest(request: GeneratedMessageV3): Metadata? = - requestMetadata.firstOrNull { it.request === request }?.metadata + private val requestMetadata = mutableListOf() + fun contextForRequest(request: Any): Context? { + return requestMetadata.firstOrNull { it.request === request } + ?.metadata + ?.let { + Context( + author = Id("0"), + languageRanges = it.preferredLocales + ) + } + } } private val server: Server @@ -55,7 +65,12 @@ class Service( require(message is GeneratedMessageV3) request = message - requestMetadata.add(RequestWithMetadata(message, headers)) + requestMetadata.add( + RequestWithMetadata( + message, + headers + ) + ) super.onMessage(message) } @@ -86,7 +101,7 @@ class Service( } fun stop() { - if (isStopped) throw IllegalStateException("$name is already stopped") + check(!isStopped) { "$name is already stopped" } isStopped = true println("Stopping $name") @@ -105,8 +120,8 @@ class Service( server.awaitTermination() } - data class RequestWithMetadata( - val request: ReqT, + data class RequestWithMetadata( + val request: Any, val metadata: Metadata? ) } diff --git a/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/Constants.kt b/common/src/main/kotlin/de/hpi/cloud/common/couchbase/Constants.kt similarity index 71% rename from common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/Constants.kt rename to common/src/main/kotlin/de/hpi/cloud/common/couchbase/Constants.kt index 1c55657..3e80527 100644 --- a/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/Constants.kt +++ b/common/src/main/kotlin/de/hpi/cloud/common/couchbase/Constants.kt @@ -1,11 +1,12 @@ -package de.hpi.cloud.common.utils.couchbase +package de.hpi.cloud.common.couchbase const val KEY_TYPE = "type" const val KEY_VERSION = "version" const val KEY_ID = "id" const val KEY_METADATA = "meta" -const val KEY_METADATA_CREATED_AT = "createdAt" const val KEY_VALUE = "value" +const val NESTED_SEPARATOR = "." + fun devDesignDoc(designDoc: String) = "dev_$designDoc" const val VIEW_BY_ID = "byId" diff --git a/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/CouchbaseCluster.kt b/common/src/main/kotlin/de/hpi/cloud/common/couchbase/CouchbaseCluster.kt similarity index 97% rename from common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/CouchbaseCluster.kt rename to common/src/main/kotlin/de/hpi/cloud/common/couchbase/CouchbaseCluster.kt index b59c3f9..c42ac19 100644 --- a/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/CouchbaseCluster.kt +++ b/common/src/main/kotlin/de/hpi/cloud/common/couchbase/CouchbaseCluster.kt @@ -1,4 +1,4 @@ -package de.hpi.cloud.common.utils.couchbase +package de.hpi.cloud.common.couchbase import com.couchbase.client.java.Bucket import com.couchbase.client.java.CouchbaseAsyncCluster diff --git a/common/src/main/kotlin/de/hpi/cloud/common/couchbase/Document.kt b/common/src/main/kotlin/de/hpi/cloud/common/couchbase/Document.kt new file mode 100644 index 0000000..2b129d1 --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/couchbase/Document.kt @@ -0,0 +1,29 @@ +package de.hpi.cloud.common.couchbase + +import com.couchbase.client.java.AsyncBucket +import com.couchbase.client.java.Bucket +import com.couchbase.client.java.document.RawJsonDocument +import de.hpi.cloud.common.entity.Entity +import de.hpi.cloud.common.entity.Id +import de.hpi.cloud.common.entity.Wrapper +import de.hpi.cloud.common.entity.entityCompanion +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonConfiguration +import rx.Observable + +val json = Json(JsonConfiguration.Stable) + +inline fun > RawJsonDocument.parseWrapper(): Wrapper { + return json.parse(Wrapper.jsonSerializerFor(), content()) +} + +inline fun > Bucket.get(id: Id): Wrapper? { + val docId = Wrapper.createDocumentId(E::class.entityCompanion().type, id) + return get(docId, RawJsonDocument::class.java)?.parseWrapper() ?: return null +} + +inline fun > AsyncBucket.get(id: Id): Observable> { + val docId = Wrapper.createDocumentId(E::class.entityCompanion().type, id) + return get(docId, RawJsonDocument::class.java) + .map { it.parseWrapper() } +} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/N1ql.kt b/common/src/main/kotlin/de/hpi/cloud/common/couchbase/N1ql.kt similarity index 66% rename from common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/N1ql.kt rename to common/src/main/kotlin/de/hpi/cloud/common/couchbase/N1ql.kt index 168088b..ad73509 100644 --- a/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/N1ql.kt +++ b/common/src/main/kotlin/de/hpi/cloud/common/couchbase/N1ql.kt @@ -1,12 +1,11 @@ -package de.hpi.cloud.common.utils.couchbase +package de.hpi.cloud.common.couchbase import com.couchbase.client.java.query.dsl.Expression import com.couchbase.client.java.query.dsl.Expression.* import com.couchbase.client.java.query.dsl.Sort import com.couchbase.client.java.query.dsl.Sort.asc import com.couchbase.client.java.query.dsl.Sort.desc -import de.hpi.cloud.common.utils.protobuf.TIMESTAMP_MILLIS -import de.hpi.cloud.common.utils.protobuf.TIMESTAMP_NANOS +import de.hpi.cloud.common.types.LocalDateTime fun and(vararg expressions: Expression?): Expression { return expressions.filterNotNull().run { @@ -16,18 +15,24 @@ fun and(vararg expressions: Expression?): Expression { } fun ascTimestamp(field: Expression): Array { - return arrayOf(asc("$field.$TIMESTAMP_MILLIS"), asc("$field.$TIMESTAMP_NANOS")) + return arrayOf( + asc("$field.${LocalDateTime.JsonSerializer.KEY_MILLIS}"), + asc("$field.${LocalDateTime.JsonSerializer.KEY_NANOS}") + ) } fun descTimestamp(field: Expression): Array { - return arrayOf(desc("$field.$TIMESTAMP_MILLIS"), desc("$field.$TIMESTAMP_NANOS")) + return arrayOf( + desc("$field.${LocalDateTime.JsonSerializer.KEY_MILLIS}"), + desc("$field.${LocalDateTime.JsonSerializer.KEY_NANOS}") + ) } /** * Builds an expression for a *nested* field with proper escaping. */ fun n(vararg part: String): Expression { - return x(part.joinToString(NESTED_SEPARATOR.toString()) { i(it).toString() }) + return x(part.joinToString(NESTED_SEPARATOR) { i(it).toString() }) } /** diff --git a/service-common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/Pagination.kt b/common/src/main/kotlin/de/hpi/cloud/common/couchbase/Pagination.kt similarity index 68% rename from service-common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/Pagination.kt rename to common/src/main/kotlin/de/hpi/cloud/common/couchbase/Pagination.kt index 4b41611..abb536d 100644 --- a/service-common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/Pagination.kt +++ b/common/src/main/kotlin/de/hpi/cloud/common/couchbase/Pagination.kt @@ -1,30 +1,36 @@ -package de.hpi.cloud.common.utils.couchbase +package de.hpi.cloud.common.couchbase import com.couchbase.client.java.Bucket -import com.couchbase.client.java.document.json.JsonObject +import com.couchbase.client.java.document.RawJsonDocument import com.couchbase.client.java.query.N1qlParams import com.couchbase.client.java.query.N1qlQuery import com.couchbase.client.java.query.Select.select +import com.couchbase.client.java.query.dsl.functions.MetaFunctions.meta import com.couchbase.client.java.query.dsl.path.AsPath import com.couchbase.client.java.query.dsl.path.LimitPath import com.couchbase.client.java.view.ViewQuery -import de.hpi.cloud.common.utils.grpc.throwException +import de.hpi.cloud.common.entity.Entity +import de.hpi.cloud.common.entity.Id +import de.hpi.cloud.common.entity.Wrapper +import de.hpi.cloud.common.grpc.throwException import de.hpi.cloud.common.utils.thenTake import io.grpc.Status +import rx.Observable -private const val PAGINATION_TOKEN_SEPARATOR = ";" -private const val PAGINATION_TOKEN_VARIANT_1 = "1" // ";" -private const val PAGINATION_TOKEN_VARIANT_2 = "2" // "" +const val PAGINATION_TOKEN_SEPARATOR = ";" +const val PAGINATION_TOKEN_VARIANT_1 = "1" // ";" +const val PAGINATION_TOKEN_VARIANT_2 = "2" // "" -fun ViewQuery.paginate( +const val FIELD_ID = "id" + +inline fun > ViewQuery.paginate( bucket: Bucket, reqPageSize: Int, reqPageToken: String, defaultPageSize: Int = 20, - maxPageSize: Int = 100, - mapper: ((JsonObject) -> T?) -): Pair, String> { + maxPageSize: Int = 100 +): Pair>, String> { val pageSize = getPageSize(reqPageSize, defaultPageSize, maxPageSize) // Apply pagination to query @@ -39,24 +45,23 @@ fun ViewQuery.paginate( // Execute query val items = execute(bucket).allRows() - val objects = items.take(pageSize) - .mapNotNull { mapper(it.document().content()) } + val entities = items.take(pageSize) + .map { it.document(RawJsonDocument::class.java).parseWrapper() } val nextPageToken = (items.size == pageSize + 1) .thenTake { items.last() } ?.let { PAGINATION_TOKEN_VARIANT_1 + PAGINATION_TOKEN_SEPARATOR + it.key().toString() + PAGINATION_TOKEN_SEPARATOR + it.id() } ?: "" // Empty string makes building the protobuf easier - return objects to nextPageToken + return entities to nextPageToken } -fun paginate( +inline fun > paginate( bucket: Bucket, queryBuilder: AsPath.() -> LimitPath, reqPageSize: Int, reqPageToken: String, defaultPageSize: Int = 20, - maxPageSize: Int = 100, - mapper: ((JsonObject) -> T?) -): Pair, String> { + maxPageSize: Int = 100 +): Pair>, String> { val pageSize = getPageSize(reqPageSize, defaultPageSize, maxPageSize) val off = (reqPageToken.isNotBlank()).thenTake { if (!reqPageToken.startsWith(PAGINATION_TOKEN_VARIANT_2)) invalidPaginationToken() @@ -65,23 +70,27 @@ fun paginate( } ?: 0 // Build query - val query = select("*").from(bucket.name()).queryBuilder() + val query = select(meta(bucket.name())[FIELD_ID]).from(bucket.name()).queryBuilder() .run { limit(pageSize + 1) } .run { offset(off) } .let { N1qlQuery.simple(it, N1qlParams.build().adhoc(false)) } // Execute query val items = bucket.query(query).allRows() - - val objects = items.take(pageSize) - .mapNotNull { mapper(it.value().getObject(bucket.name())) } + val ids = items.take(pageSize) + .map { it.value().getString(FIELD_ID) } + val entities = Observable.from(ids) + .flatMap { bucket.async().get(Id(it)) } + .toList() + .toBlocking() + .single() val nextPageToken = (items.size == pageSize + 1) .thenTake { PAGINATION_TOKEN_VARIANT_2 + PAGINATION_TOKEN_SEPARATOR + (off + pageSize) } ?: "" // Empty string makes building the protobuf easier - return objects to nextPageToken + return entities to nextPageToken } -private fun getPageSize( +fun getPageSize( reqPageSize: Int, defaultPageSize: Int = 20, maxPageSize: Int = 100 @@ -97,6 +106,6 @@ private fun getPageSize( } } -private fun invalidPaginationToken(): Nothing { +fun invalidPaginationToken(): Nothing { Status.INVALID_ARGUMENT.throwException("Invalid pagination token") } diff --git a/common/src/main/kotlin/de/hpi/cloud/common/entity/Entity.kt b/common/src/main/kotlin/de/hpi/cloud/common/entity/Entity.kt new file mode 100644 index 0000000..5c8aa83 --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/entity/Entity.kt @@ -0,0 +1,71 @@ +package de.hpi.cloud.common.entity + +import com.google.protobuf.GeneratedMessageV3 +import de.hpi.cloud.common.Context +import de.hpi.cloud.common.Persistable +import de.hpi.cloud.common.types.L10n +import kotlinx.serialization.ImplicitReflectionSerializer +import kotlinx.serialization.KSerializer +import kotlinx.serialization.serializer +import java.net.URI +import kotlin.reflect.KClass +import kotlin.reflect.full.companionObjectInstance + +abstract class Entity> : Persistable() { + abstract class Companion>( + val type: String, + val version: Int = 1 + ) + + abstract class ProtoSerializer, Proto : GeneratedMessageV3> : Persistable.ProtoSerializer { + abstract override fun toProto(@Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") entity: E, context: Context): Proto + fun toProto(wrapper: Wrapper, context: Context): Proto = toProto(wrapper.value, context).apply { + this::class.java.getMethod("setId", String::class.java).invoke(this, wrapper.id) + } + } + + fun companion(): Companion { + @Suppress("UNCHECKED_CAST") + return this::class.companionObjectInstance as Companion + } + + fun createNewWrapper( + context: Context, + id: Id = Id.random(), + sources: List> = emptyList(), + permissions: Permissions = emptyMap(), + published: Boolean = true + ): Wrapper { + val companion = companion() + + @Suppress("UNCHECKED_CAST") + return Wrapper.create( + context = context, + companion = companion, + id = id, + sources = sources, + permissions = permissions, + value = this as E, + published = published + ) + } +} + +fun > KClass.entityCompanion(): Entity.Companion { + @Suppress("UNCHECKED_CAST") + return this::class.companionObjectInstance as Entity.Companion +} + +@UseExperimental(ImplicitReflectionSerializer::class) +fun > KClass.jsonSerializer(): KSerializer { + return this.serializer() +} + +fun

, Proto : GeneratedMessageV3> KClass

.protoSerializer(): Persistable.ProtoSerializer { + @Suppress("UNCHECKED_CAST") + return this.nestedClasses.first { it.simpleName == "ProtoSerializer" }.objectInstance as Persistable.ProtoSerializer +} + +inline fun > GeneratedMessageV3.parse(context: Context): P { + return P::class.protoSerializer().fromProto(this, context) +} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/entity/Events.kt b/common/src/main/kotlin/de/hpi/cloud/common/entity/Events.kt new file mode 100644 index 0000000..4f1d177 --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/entity/Events.kt @@ -0,0 +1,128 @@ +package de.hpi.cloud.common.entity + +import de.hpi.cloud.common.Context +import de.hpi.cloud.common.Party +import de.hpi.cloud.common.types.L10n +import de.hpi.cloud.common.types.LocalDateTime +import kotlinx.serialization.Serializable +import java.net.URI +import java.time.LocalDateTime as RawLocalDateTime + +@Serializable +sealed class Event( + val author: Id, + val timestamp: LocalDateTime = LocalDateTime.now() +) + +class CreateEvent private constructor( + author: Id, + timestamp: LocalDateTime = LocalDateTime.now() +) : Event(author, timestamp) { + companion object { + fun create(context: Context): CreateEvent = + CreateEvent( + author = context.author + ) + } +} + +class UpdateEvent> private constructor( + author: Id, + timestamp: LocalDateTime = LocalDateTime.now(), + val oldValue: E +) : Event(author, timestamp) { + companion object { + fun > create(context: Context, wrapper: Wrapper): UpdateEvent = + UpdateEvent( + author = context.author, + oldValue = wrapper.value + ) + } +} + +class KeepAliveEvent private constructor( + author: Id, + timestamp: LocalDateTime = LocalDateTime.now() +) : Event(author, timestamp) { + companion object { + fun create(context: Context): KeepAliveEvent = + KeepAliveEvent( + author = context.author + ) + } +} + +class SourcesChangeEvent private constructor( + author: Id, + timestamp: LocalDateTime = LocalDateTime.now(), + val oldSources: List> +) : Event(author, timestamp) { + companion object { + fun > create(context: Context, wrapper: Wrapper): SourcesChangeEvent = + SourcesChangeEvent( + author = context.author, + oldSources = wrapper.metadata.sources + ) + } +} + +class PermissionsChangeEvent private constructor( + author: Id, + timestamp: LocalDateTime = LocalDateTime.now(), + val oldPermissions: Permissions +) : Event(author, timestamp) { + companion object { + fun > create(context: Context, wrapper: Wrapper): PermissionsChangeEvent = + PermissionsChangeEvent( + author = context.author, + oldPermissions = wrapper.metadata.permissions + ) + } +} + +abstract class DelayedEvent( + author: Id, + timestamp: LocalDateTime = LocalDateTime.now(), + val effectiveFrom: LocalDateTime? = null +) : Event(author, timestamp) { + val isEffectiveNow: Boolean + get() = effectiveFrom == null || effectiveFrom.value < RawLocalDateTime.now() +} + +class DeletedChangeEvent private constructor( + author: Id, + timestamp: LocalDateTime = LocalDateTime.now(), + val isDeleted: Boolean, + effectiveFrom: LocalDateTime? = null +) : DelayedEvent(author, timestamp, effectiveFrom) { + companion object { + fun > create( + context: Context, + isDeleted: Boolean, + effectiveFrom: LocalDateTime? = null + ): DeletedChangeEvent = DeletedChangeEvent( + author = context.author, + isDeleted = isDeleted, + effectiveFrom = effectiveFrom + ) + } +} + +class PublishedChangeEvent private constructor( + author: Id, + timestamp: LocalDateTime = LocalDateTime.now(), + val isPublished: Boolean, + effectiveFrom: LocalDateTime? = null +) : DelayedEvent(author, timestamp, effectiveFrom) { + companion object { + fun > create( + context: Context, + isPublished: Boolean, + effectiveFrom: LocalDateTime? = null + ): PublishedChangeEvent = PublishedChangeEvent( + author = context.author, + isPublished = isPublished, + effectiveFrom = effectiveFrom + ) + } +} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/entity/Id.kt b/common/src/main/kotlin/de/hpi/cloud/common/entity/Id.kt new file mode 100644 index 0000000..7777982 --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/entity/Id.kt @@ -0,0 +1,12 @@ +package de.hpi.cloud.common.entity + +import kotlinx.serialization.Serializable +import java.util.* + +@Suppress("unused") +@Serializable +data class Id>(val value: String) { + companion object { + fun > random(): Id = Id(UUID.randomUUID().toString()) + } +} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/entity/Metadata.kt b/common/src/main/kotlin/de/hpi/cloud/common/entity/Metadata.kt new file mode 100644 index 0000000..aedc1c5 --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/entity/Metadata.kt @@ -0,0 +1,15 @@ +package de.hpi.cloud.common.entity + +import de.hpi.cloud.common.serializers.UriSerializer +import de.hpi.cloud.common.types.L10n +import kotlinx.serialization.Serializable +import java.net.URI + +@Serializable +data class Metadata( + val sources: List>, + val permissions: Permissions, + val events: List +) { + inline fun eventsOfType(): List = events.filterIsInstance() +} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/entity/Permissions.kt b/common/src/main/kotlin/de/hpi/cloud/common/entity/Permissions.kt new file mode 100644 index 0000000..cdd1b4f --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/entity/Permissions.kt @@ -0,0 +1,12 @@ +package de.hpi.cloud.common.entity + +import de.hpi.cloud.common.Party +import kotlinx.serialization.Serializable + +@Serializable +data class Permission( + val read: Boolean? = null, + val write: Boolean? = null, + val create: Boolean? = null +) +typealias Permissions = Map, Permission> diff --git a/common/src/main/kotlin/de/hpi/cloud/common/entity/Wrapper.kt b/common/src/main/kotlin/de/hpi/cloud/common/entity/Wrapper.kt new file mode 100644 index 0000000..f403bd4 --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/entity/Wrapper.kt @@ -0,0 +1,177 @@ +package de.hpi.cloud.common.entity + +import de.hpi.cloud.common.Context +import de.hpi.cloud.common.types.L10n +import de.hpi.cloud.common.types.LocalDateTime +import kotlinx.serialization.* +import kotlinx.serialization.internal.SerialClassDescImpl +import java.net.URI + +@Serializable(with = Wrapper.JsonSerializer::class) +data class Wrapper>( + val type: String, + val version: Int = 1, + val id: Id, + val metadata: Metadata, + val value: E +) { + companion object { + fun > create( + context: Context, + companion: Entity.Companion, + id: Id, + sources: List> = emptyList(), + permissions: Permissions = emptyMap(), + value: E, + published: Boolean = true + ): Wrapper { + return Wrapper( + type = companion.type, + version = companion.version, + id = id, + metadata = Metadata( + sources = sources, + permissions = permissions, + events = listOf(CreateEvent.create(context)) + ), + value = value + ) + } + + fun > createDocumentId(type: String, id: Id): String = "$type:$id" + + inline fun > jsonSerializerFor(): JsonSerializer = + JsonSerializer(E::class.jsonSerializer()) + } + + @Serializer(forClass = Wrapper::class) + class JsonSerializer>( + private val entitySerializer: KSerializer + ) : KSerializer> { + override val descriptor = object : SerialClassDescImpl("Wrapper") { + init { + addElement("type") + addElement("version") + addElement("id") + addElement("metadata") + addElement("value") + } + } + + override fun serialize(encoder: Encoder, obj: Wrapper) { + encoder.beginStructure(descriptor).let { + it.encodeStringElement(descriptor, 0, obj.type) + it.encodeIntElement(descriptor, 1, obj.version) + it.encodeStringElement(descriptor, 2, obj.id.value) + it.encodeSerializableElement(descriptor, 3, Metadata.serializer(), obj.metadata) + it.encodeSerializableElement(descriptor, 4, entitySerializer, obj.value) + } + } + + override fun deserialize(decoder: Decoder): Wrapper { + val dec = decoder.beginStructure(descriptor) + var type: String? = null + var version: Int? = null + var id: Id? = null + var metadata: Metadata? = null + var value: E? = null + + loop@ while (true) { + when (val i = dec.decodeElementIndex(descriptor)) { + CompositeDecoder.READ_DONE -> break@loop + 0 -> type = dec.decodeStringElement(descriptor, i) + 1 -> version = dec.decodeIntElement(descriptor, i) + 2 -> id = Id(dec.decodeStringElement(descriptor, i)) + 3 -> metadata = dec.decodeSerializableElement(descriptor, i, Metadata.serializer()) + 4 -> value = dec.decodeSerializableElement(descriptor, i, entitySerializer) + else -> throw SerializationException("Unknown index $i") + } + } + dec.endStructure(descriptor) + return Wrapper( + type ?: throw MissingFieldException("type"), + version ?: throw MissingFieldException("version"), + id ?: throw MissingFieldException("id"), + metadata ?: throw MissingFieldException("metadata"), + value ?: throw MissingFieldException("value") + ) + } + } + + val documentId: String + get() = Companion.createDocumentId(type, id) + + // region Mutation + fun withValue(context: Context, newValue: E): Wrapper { + return if (value == newValue) withKeepAlive(context) + else copy( + metadata = metadata.copy( + events = metadata.events + UpdateEvent.create(context, this) + ), + value = newValue + ) + } + + fun withKeepAlive(context: Context): Wrapper { + return copy( + metadata = metadata.copy( + events = metadata.events + KeepAliveEvent.create(context) + ) + ) + } + + fun withSources(context: Context, newSources: List>): Wrapper { + return if (metadata.sources == newSources) this + else copy( + metadata = metadata.copy( + sources = newSources, + events = metadata.events + SourcesChangeEvent.create(context, this) + ) + ) + } + + fun withPermissions(context: Context, newPermissions: Permissions): Wrapper { + return if (metadata.permissions == newPermissions) this + else copy( + metadata = metadata.copy( + permissions = newPermissions, + events = metadata.events + PermissionsChangeEvent.create(context, this) + ) + ) + } + + val isDeleted: Boolean + get() = metadata.eventsOfType() + .filter { it.isEffectiveNow } + .sumBy { if (it.isDeleted) -1 else 1 } < 0 + + fun withDeleted( + context: Context, + isDeleted: Boolean, + effectiveFrom: LocalDateTime = LocalDateTime.now() + ): Wrapper { + return copy( + metadata = metadata.copy( + events = metadata.events + DeletedChangeEvent.create(context, isDeleted, effectiveFrom) + ) + ) + } + + val isPublished: Boolean + get() = metadata.eventsOfType() + .filter { it.isEffectiveNow } + .sumBy { if (it.isPublished) 1 else -1 } > 0 + + fun withPublished( + context: Context, + isPublished: Boolean, + effectiveFrom: LocalDateTime = LocalDateTime.now() + ): Wrapper { + return copy( + metadata = metadata.copy( + events = metadata.events + PublishedChangeEvent.create(context, isPublished, effectiveFrom) + ) + ) + } + // endregion +} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/utils/grpc/Metadata.kt b/common/src/main/kotlin/de/hpi/cloud/common/grpc/Metadata.kt similarity index 94% rename from common/src/main/kotlin/de/hpi/cloud/common/utils/grpc/Metadata.kt rename to common/src/main/kotlin/de/hpi/cloud/common/grpc/Metadata.kt index cacdf33..657f069 100644 --- a/common/src/main/kotlin/de/hpi/cloud/common/utils/grpc/Metadata.kt +++ b/common/src/main/kotlin/de/hpi/cloud/common/grpc/Metadata.kt @@ -1,4 +1,4 @@ -package de.hpi.cloud.common.utils.grpc +package de.hpi.cloud.common.grpc import io.grpc.Metadata import java.util.* @@ -8,7 +8,6 @@ private val KEY_ACCEPT_LANGUAGE = Metadata.Key.of(HEADER_ACCEPT_LANGUAGE, Simple object SimpleAsciiMarshaller : Metadata.AsciiMarshaller { override fun toAsciiString(value: String?): String = value ?: ""; - override fun parseAsciiString(serialized: String?): String = serialized ?: ""; } diff --git a/service-common/src/main/kotlin/de/hpi/cloud/common/utils/grpc/ServiceImpl.kt b/common/src/main/kotlin/de/hpi/cloud/common/grpc/ServiceImpl.kt similarity index 78% rename from service-common/src/main/kotlin/de/hpi/cloud/common/utils/grpc/ServiceImpl.kt rename to common/src/main/kotlin/de/hpi/cloud/common/grpc/ServiceImpl.kt index 7449c6a..0232fe5 100644 --- a/service-common/src/main/kotlin/de/hpi/cloud/common/utils/grpc/ServiceImpl.kt +++ b/common/src/main/kotlin/de/hpi/cloud/common/grpc/ServiceImpl.kt @@ -1,10 +1,13 @@ -package de.hpi.cloud.common.utils.grpc +package de.hpi.cloud.common.grpc +import de.hpi.cloud.common.Context +import de.hpi.cloud.common.Service import io.grpc.BindableService import io.grpc.Status import io.grpc.StatusRuntimeException import io.grpc.stub.StreamObserver +@Suppress("unused") // Receiver is used for scoping fun BindableService.catchErrors( request: Req?, responseObserver: StreamObserver?, @@ -32,9 +35,10 @@ fun BindableService.unary( request: Req?, responseObserver: StreamObserver?, methodName: String, - lambda: (Req) -> Res + lambda: (Context, Req) -> Res ) = catchErrors(request, responseObserver) { req, res -> println("${this::class.java.simpleName}.$methodName called") - res.onNext(lambda(req)) + val context = Service.contextForRequest(req) ?: throw IllegalStateException("Metadata not found") + res.onNext(lambda(context, req)) res.onCompleted() } diff --git a/service-common/src/main/kotlin/de/hpi/cloud/common/utils/grpc/Status.kt b/common/src/main/kotlin/de/hpi/cloud/common/grpc/Status.kt similarity index 86% rename from service-common/src/main/kotlin/de/hpi/cloud/common/utils/grpc/Status.kt rename to common/src/main/kotlin/de/hpi/cloud/common/grpc/Status.kt index 53b6a1b..2e6c0f8 100644 --- a/service-common/src/main/kotlin/de/hpi/cloud/common/utils/grpc/Status.kt +++ b/common/src/main/kotlin/de/hpi/cloud/common/grpc/Status.kt @@ -1,5 +1,6 @@ -package de.hpi.cloud.common.utils.grpc +package de.hpi.cloud.common.grpc +import com.google.protobuf.GeneratedMessageV3 import io.grpc.Status fun checkArgNotSet(arg: String?, argName: String) { @@ -19,7 +20,7 @@ fun argRequired(argName: String, ifArgSet: String? = null) { + (ifArgSet?.let { " if argument $it is set" } ?: "")) } -inline fun notFound(id: String): Nothing { +inline fun notFound(id: String): Nothing { Status.NOT_FOUND.throwException("${M::class.java.simpleName} with ID $id not found") } diff --git a/common/src/main/kotlin/de/hpi/cloud/common/protobuf/Builder.kt b/common/src/main/kotlin/de/hpi/cloud/common/protobuf/Builder.kt new file mode 100644 index 0000000..368644b --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/protobuf/Builder.kt @@ -0,0 +1,15 @@ +package de.hpi.cloud.common.protobuf + +import com.google.protobuf.GeneratedMessageV3 +import com.google.protobuf.GeneratedMessageV3.Builder + +inline fun > B.build(builder: B.() -> Unit): M { + builder() + @Suppress("UNCHECKED_CAST") + return build() as M +} +inline fun > B.build(source: T, builder: B.(T) -> Unit): M { + builder(source) + @Suppress("UNCHECKED_CAST") + return build() as M +} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/serializers/Currency.kt b/common/src/main/kotlin/de/hpi/cloud/common/serializers/Currency.kt new file mode 100644 index 0000000..e7b0959 --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/serializers/Currency.kt @@ -0,0 +1,13 @@ +package de.hpi.cloud.common.serializers + +import kotlinx.serialization.* +import kotlinx.serialization.internal.StringDescriptor +import java.util.* + +@Serializer(forClass = Currency::class) +object CurrencySerializer : KSerializer { + override val descriptor = StringDescriptor.withName("AsString") + + override fun serialize(encoder: Encoder, obj: Currency) = encoder.encodeString(obj.currencyCode) + override fun deserialize(decoder: Decoder): Currency = Currency.getInstance(decoder.decodeString()) +} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/serializers/EnumSerializer.kt b/common/src/main/kotlin/de/hpi/cloud/common/serializers/EnumSerializer.kt new file mode 100644 index 0000000..a5c7f60 --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/serializers/EnumSerializer.kt @@ -0,0 +1,21 @@ +package de.hpi.cloud.common.serializers + +import kotlinx.serialization.* +import kotlinx.serialization.internal.StringDescriptor +import kotlin.reflect.KClass + +abstract class EnumSerializer>( + private val kClass: KClass, + private val fallback: E +) : KSerializer { + override val descriptor: SerialDescriptor = StringDescriptor + + override fun serialize(encoder: Encoder, obj: E) = encoder.encodeString(obj.name.toLowerCase()) + + override fun deserialize(decoder: Decoder): E { + val value = decoder.decodeString() + return kClass.enumMembers() + .firstOrNull { it.name.equals(value, ignoreCase = true) } + ?: fallback + } +} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/serializers/Locale.kt b/common/src/main/kotlin/de/hpi/cloud/common/serializers/Locale.kt new file mode 100644 index 0000000..6edcab6 --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/serializers/Locale.kt @@ -0,0 +1,13 @@ +package de.hpi.cloud.common.serializers + +import kotlinx.serialization.* +import kotlinx.serialization.internal.StringDescriptor +import java.util.* + +@Serializer(forClass = Locale::class) +object LocaleSerializer : KSerializer { + override val descriptor = StringDescriptor.withName("AsString") + + override fun serialize(encoder: Encoder, obj: Locale) = encoder.encodeString(obj.toLanguageTag()) + override fun deserialize(decoder: Decoder): Locale = Locale.forLanguageTag(decoder.decodeString()) +} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/serializers/Uri.kt b/common/src/main/kotlin/de/hpi/cloud/common/serializers/Uri.kt new file mode 100644 index 0000000..167c46e --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/serializers/Uri.kt @@ -0,0 +1,14 @@ +package de.hpi.cloud.common.serializers + +import de.hpi.cloud.common.utils.parseUri +import kotlinx.serialization.* +import kotlinx.serialization.internal.StringDescriptor +import java.net.URI + +@Serializer(forClass = URI::class) +object UriSerializer : KSerializer { + override val descriptor = StringDescriptor.withName("AsString") + + override fun serialize(encoder: Encoder, obj: URI) = encoder.encodeString(obj.toString()) + override fun deserialize(decoder: Decoder): URI = decoder.decodeString().parseUri() +} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/types/DateTime.kt b/common/src/main/kotlin/de/hpi/cloud/common/types/DateTime.kt new file mode 100644 index 0000000..4df881f --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/types/DateTime.kt @@ -0,0 +1,97 @@ +package de.hpi.cloud.common.types + +import com.google.protobuf.Timestamp +import de.hpi.cloud.common.Context +import de.hpi.cloud.common.Persistable +import de.hpi.cloud.common.protobuf.build +import kotlinx.serialization.* +import kotlinx.serialization.internal.SerialClassDescImpl +import java.time.ZoneOffset +import java.time.LocalDateTime as RawLocalDateTime + +@Serializable(with = LocalDateTime.JsonSerializer::class) +data class LocalDateTime(val value: RawLocalDateTime) : Persistable() { + companion object { + const val MILLIS_IN_SECOND = 1_000 + const val NANOS_IN_MILLI = 1_000_000 + + fun now(): LocalDateTime = LocalDateTime(RawLocalDateTime.now()) + + fun fromMillisNanos(millis: Long, nanos: Int): LocalDateTime = LocalDateTime( + value = RawLocalDateTime.ofEpochSecond( + millis / MILLIS_IN_SECOND, + (millis % MILLIS_IN_SECOND).toInt() * NANOS_IN_MILLI + nanos, + ZoneOffset.UTC + ) + ) + + fun fromSecondsNanos(seconds: Long, nanos: Int): LocalDateTime = LocalDateTime( + value = RawLocalDateTime.ofEpochSecond(seconds, nanos, ZoneOffset.UTC) + ) + } + + object ProtoSerializer : Persistable.ProtoSerializer { + override fun fromProto(proto: Timestamp, context: Context): LocalDateTime = + fromSecondsNanos(proto.seconds, proto.nanos) + + override fun toProto(persistable: LocalDateTime, context: Context): Timestamp = + Timestamp.newBuilder().build(persistable) { + val (s, n) = it.secondsNanos + seconds = s + nanos = n + } + } + + @Serializer(forClass = LocalDateTime::class) + object JsonSerializer : KSerializer { + const val KEY_MILLIS = "millis" + const val KEY_NANOS = "nanos" + + override val descriptor = object : SerialClassDescImpl("LocalDateTime") { + init { + addElement(KEY_MILLIS) + addElement(KEY_NANOS) + } + } + + override fun serialize(encoder: Encoder, obj: LocalDateTime) { + val (millis, nanos) = obj.millisNanos + encoder.beginStructure(descriptor).let { + it.encodeLongElement(descriptor, 0, millis) + it.encodeIntElement(descriptor, 1, nanos) + } + } + + override fun deserialize(decoder: Decoder): LocalDateTime { + val dec = decoder.beginStructure(descriptor) + var millis: Long? = null + var nanos: Int? = null + + loop@ while (true) { + when (val i = dec.decodeElementIndex(descriptor)) { + CompositeDecoder.READ_DONE -> break@loop + 0 -> millis = dec.decodeLongElement(descriptor, i) + 1 -> nanos = dec.decodeIntElement(descriptor, i) + else -> throw SerializationException("Unknown index $i") + } + } + dec.endStructure(descriptor) + + return fromMillisNanos( + millis = millis ?: throw MissingFieldException("millis"), + nanos = nanos ?: throw MissingFieldException("nanos") + ) + } + } + + val secondsNanos: Pair + get() = value.toEpochSecond(ZoneOffset.UTC) to value.nano + val millisNanos: Pair + get() { + val millis = value.toEpochSecond(ZoneOffset.UTC) * MILLIS_IN_SECOND + value.nano / NANOS_IN_MILLI + val nanos = value.nano % NANOS_IN_MILLI + return millis to nanos + } +} + +fun LocalDateTime.toProto(context: Context): Timestamp = LocalDateTime.ProtoSerializer.toProto(this, context) diff --git a/common/src/main/kotlin/de/hpi/cloud/common/types/Image.kt b/common/src/main/kotlin/de/hpi/cloud/common/types/Image.kt new file mode 100644 index 0000000..c1f5c76 --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/types/Image.kt @@ -0,0 +1,56 @@ +package de.hpi.cloud.common.types + +import de.hpi.cloud.common.Context +import de.hpi.cloud.common.Persistable +import de.hpi.cloud.common.protobuf.build +import de.hpi.cloud.common.serializers.EnumSerializer +import de.hpi.cloud.common.serializers.UriSerializer +import de.hpi.cloud.common.utils.parseUri +import kotlinx.serialization.Serializable +import java.net.URI +import de.hpi.cloud.common.v1test.Image as ProtoImage + +@Serializable +data class Image( + val source: Map, + val alt: L10n, + val aspectRatio: Float? = null +) : Persistable() { + object ProtoSerializer : Persistable.ProtoSerializer { + override fun fromProto(proto: ProtoImage, context: Context): Image { + return Image( + source = mapOf(Size.ORIGINAL to proto.source.parseUri()), + alt = L10n.single(context, proto.alt), + aspectRatio = proto.aspectRatio + ) + } + + override fun toProto(persistable: Image, context: Context): ProtoImage = + toProto(persistable, context, Size.ORIGINAL) + + fun toProto(persistable: Image, context: Context, size: Size = Size.ORIGINAL): ProtoImage = + ProtoImage.newBuilder().build(persistable) { + source = it.source.bestMatch(size).toString() + alt = it.alt[context] + it.aspectRatio?.let { a -> aspectRatio = a } + } + } + + @Serializable(with = Size.Serializer::class) + enum class Size { + ORIGINAL; + + object Serializer : EnumSerializer(Size::class, ORIGINAL) + } +} + +fun Image.toProto(context: Context): ProtoImage = Image.ProtoSerializer.toProto(this, context) + + +fun Map.bestMatch(size: Image.Size): T { + require(isNotEmpty()) { "At least one size must be available" } + + return this[size] + ?: this[Image.Size.ORIGINAL] + ?: values.first() +} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/types/L10n.kt b/common/src/main/kotlin/de/hpi/cloud/common/types/L10n.kt new file mode 100644 index 0000000..5507706 --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/types/L10n.kt @@ -0,0 +1,49 @@ +package de.hpi.cloud.common.types + +import de.hpi.cloud.common.Context +import de.hpi.cloud.common.serializers.LocaleSerializer +import kotlinx.serialization.Serializable +import java.util.* + + +val LOCALE_FALLBACK: Locale = Locale.ENGLISH + +@Serializable +data class L10n( + val values: Map<@Serializable(LocaleSerializer::class) Locale, T> +) { + companion object { + fun single(locale: Locale, value: T): L10n = L10n(mapOf(locale to value)) + + fun single(context: Context, value: T): L10n = single(context.languageRanges.locale, value) + + fun from(en: T? = null, de: T? = null): L10n = + L10n( + values = listOfNotNull( + en?.let { Locale.ENGLISH to en }, + de?.let { Locale.GERMAN to de } + ).toMap() + ) + } + + operator fun get(context: Context): T = get(context.languageRanges) + operator fun get(languageRanges: List): T = + values.getValue(values.keys.toList().bestMatch(languageRanges)) +} + +fun T.l10n(locale: Locale = Locale.ENGLISH): L10n = L10n.single(locale, this) +fun T.l10n(context: Context): L10n = l10n(context.languageRanges.locale) + +fun String.parseLocale(): Locale = Locale.forLanguageTag(this) +fun List.bestMatch(languageRanges: List): Locale { + require(isNotEmpty()) { "At least one locale must be available" } + + val tagStrings = map { it.toString() } + return Locale.lookupTag(languageRanges, tagStrings) + ?.let { this[tagStrings.indexOf(it)] } + ?: LOCALE_FALLBACK + ?: first() +} + +val List.locale: Locale + get() = maxBy { it.weight }?.range?.parseLocale() ?: LOCALE_FALLBACK diff --git a/common/src/main/kotlin/de/hpi/cloud/common/types/Money.kt b/common/src/main/kotlin/de/hpi/cloud/common/types/Money.kt new file mode 100644 index 0000000..a11633b --- /dev/null +++ b/common/src/main/kotlin/de/hpi/cloud/common/types/Money.kt @@ -0,0 +1,47 @@ +package de.hpi.cloud.common.types + +import de.hpi.cloud.common.Context +import de.hpi.cloud.common.Persistable +import de.hpi.cloud.common.protobuf.build +import de.hpi.cloud.common.serializers.CurrencySerializer +import kotlinx.serialization.Serializable +import java.util.* +import com.google.type.Money as ProtoMoney + +@Serializable +data class Money( + @Serializable(CurrencySerializer::class) + val currencyCode: Currency, + val units: Long, + val nanos: Int +) : Persistable() { + companion object { + const val CURRENCY_EUR = "EUR" + const val NANOS_IN_UNIT = 1_000_000_000 + + fun eur(value: Number): Money = Money( + currencyCode = Currency.getInstance(CURRENCY_EUR), + units = value.toLong(), + nanos = ((value.toDouble() % 1) * NANOS_IN_UNIT).toInt() + ) + } + + object ProtoSerializer : Persistable.ProtoSerializer { + override fun fromProto(proto: ProtoMoney, context: Context): Money { + return Money( + currencyCode = Currency.getInstance(proto.currencyCode), + units = proto.units, + nanos = proto.nanos + ) + } + + override fun toProto(persistable: Money, context: Context): ProtoMoney = + ProtoMoney.newBuilder().build(persistable) { + currencyCode = it.currencyCode.toString() + units = it.units + nanos = it.nanos + } + } +} + +fun Money.toProto(context: Context): ProtoMoney = Money.ProtoSerializer.toProto(this, context) diff --git a/common/src/main/kotlin/de/hpi/cloud/common/utils/String.kt b/common/src/main/kotlin/de/hpi/cloud/common/utils/String.kt index 519e0fb..88e2649 100644 --- a/common/src/main/kotlin/de/hpi/cloud/common/utils/String.kt +++ b/common/src/main/kotlin/de/hpi/cloud/common/utils/String.kt @@ -1,5 +1,20 @@ package de.hpi.cloud.common.utils +import de.hpi.cloud.common.types.L10n + +fun String.trimNotEmpty(): String = trim().also { + require(it.isNotEmpty()) { "String must not be blank" } +} + +fun L10n.trimNotEmpty(): L10n = + L10n( + values.mapValues { (locale, value) -> + value.trim().also { + require(it.isNotEmpty()) { "String must not be blank (locale $locale" } + } + } + ) + fun String.cut(vararg delimiters: Char, ignoreCase: Boolean = false) = indexOfAny(delimiters, ignoreCase = ignoreCase).let { index -> if (index != -1) substring(0, index) diff --git a/common/src/main/kotlin/de/hpi/cloud/common/utils/Uri.kt b/common/src/main/kotlin/de/hpi/cloud/common/utils/Uri.kt index 75d9a72..4c24f9f 100644 --- a/common/src/main/kotlin/de/hpi/cloud/common/utils/Uri.kt +++ b/common/src/main/kotlin/de/hpi/cloud/common/utils/Uri.kt @@ -3,12 +3,11 @@ package de.hpi.cloud.common.utils import java.net.URI import java.net.URISyntaxException +fun String.parseUri(): URI = URI(this) fun String.tryParseUri(): URI? { return try { - URI(this) + parseUri() } catch (e: URISyntaxException) { null } } - -fun String.toUri() = URI(this) diff --git a/common/src/main/kotlin/de/hpi/cloud/common/utils/Url.kt b/common/src/main/kotlin/de/hpi/cloud/common/utils/Url.kt deleted file mode 100644 index e560d36..0000000 --- a/common/src/main/kotlin/de/hpi/cloud/common/utils/Url.kt +++ /dev/null @@ -1,15 +0,0 @@ -package de.hpi.cloud.common.utils - -import java.net.MalformedURLException -import java.net.URL - - -fun String.tryParseUrl(): URL? { - return try { - URL(this) - } catch (e: MalformedURLException) { - null - } -} - -fun String.toUrl() = URL(this) diff --git a/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/Bucket.kt b/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/Bucket.kt deleted file mode 100644 index b0cc69d..0000000 --- a/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/Bucket.kt +++ /dev/null @@ -1,17 +0,0 @@ -package de.hpi.cloud.common.utils.couchbase - -import com.couchbase.client.java.Bucket -import com.couchbase.client.java.document.json.JsonObject -import com.couchbase.client.java.view.ViewQuery -import com.couchbase.client.java.view.ViewRow - -fun Bucket.querySingle(query: ViewQuery): ViewRow? { - return query(query.limit(1)).allRows().firstOrNull() -} - -fun Bucket.get(design: String, view: String, key: String): ViewRow? { - return querySingle(ViewQuery.from(design, view).key(key)) -} -fun Bucket.getContent(design: String, view: String, key: String): JsonObject? { - return get(design, view, key)?.document()?.content() -} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/Builder.kt b/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/Builder.kt deleted file mode 100644 index f6bf501..0000000 --- a/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/Builder.kt +++ /dev/null @@ -1,31 +0,0 @@ -package de.hpi.cloud.common.utils.couchbase - -import com.couchbase.client.java.document.JsonDocument -import com.couchbase.client.java.document.json.JsonObject -import de.hpi.cloud.common.utils.protobuf.timestampNow -import de.hpi.cloud.common.utils.protobuf.toDbMap - -fun documentId(id: String, type: String) = "$type:$id" - -fun buildJsonDocument( - id: String, - type: String, - version: Int, - value: Map, - meta: Map? = null -): JsonDocument { - return JsonDocument.create( - documentId(id, type), - JsonObject.from( - mapOf( - KEY_TYPE to type, - KEY_VERSION to version, - KEY_ID to id, - KEY_METADATA to mapOf( - KEY_METADATA_CREATED_AT to timestampNow().toDbMap() - ) + meta.orEmpty(), - KEY_VALUE to value - ) - ) - ) -} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/I18n.kt b/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/I18n.kt deleted file mode 100644 index 04bf8d3..0000000 --- a/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/I18n.kt +++ /dev/null @@ -1,9 +0,0 @@ -package de.hpi.cloud.common.utils.couchbase - -import de.hpi.cloud.common.utils.mapOfNotNull - -fun i18nSingle(content: String?, language: String) = content?.let { mapOf(language to content) } ?: emptyMap() -fun i18nMap(de: String? = null, en: String? = null) = mapOfNotNull( - de?.let { "de" to it }, - en?.let { "en" to it } -) diff --git a/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/JsonObject.kt b/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/JsonObject.kt deleted file mode 100644 index 78c2938..0000000 --- a/common/src/main/kotlin/de/hpi/cloud/common/utils/couchbase/JsonObject.kt +++ /dev/null @@ -1,44 +0,0 @@ -package de.hpi.cloud.common.utils.couchbase - -import com.couchbase.client.java.document.json.JsonArray -import com.couchbase.client.java.document.json.JsonObject - -const val NESTED_SEPARATOR = '.' - -fun JsonObject.getNested(name: String, getter: JsonObject.(String) -> T): T? { - val obj = - if (!name.contains(NESTED_SEPARATOR)) this - else getNestedObject(name.substringBeforeLast(NESTED_SEPARATOR, "")) - return obj?.let { getter(name.substringAfterLast(NESTED_SEPARATOR)) } -} - -// region Object -fun JsonObject.getNestedObject(name: String): JsonObject? { - return name.split(NESTED_SEPARATOR).fold(this) { obj, part -> obj?.getObject(part) } -} -// endregion - -// region String -fun JsonObject.getNestedString(name: String): String? { - return getNested(name, JsonObject::getString) -} -// endregion - -// region Primitives -fun JsonObject.getNestedInt(name: String): Int? { - return getNested(name, JsonObject::getInt) -} -// endregion - -// region Array -fun JsonObject.getNestedArray(name: String): JsonArray? { - return getNested(name, JsonObject::getArray) -} - -fun JsonObject.getStringArray(name: String): List { - return getNestedArray(name) - ?.toList() - ?.map { it as? String } - ?: emptyList() -} -// endregion diff --git a/common/src/main/kotlin/de/hpi/cloud/common/utils/grpc/Builder.kt b/common/src/main/kotlin/de/hpi/cloud/common/utils/grpc/Builder.kt deleted file mode 100644 index fc76389..0000000 --- a/common/src/main/kotlin/de/hpi/cloud/common/utils/grpc/Builder.kt +++ /dev/null @@ -1,25 +0,0 @@ -package de.hpi.cloud.common.utils.grpc - -import com.couchbase.client.java.document.json.JsonObject -import com.google.protobuf.GeneratedMessageV3.Builder -import de.hpi.cloud.common.utils.couchbase.KEY_VALUE - -inline fun > B.buildWith(builder: B.() -> Unit): M { - builder() - @Suppress("UNCHECKED_CAST") - return build() as M -} - -inline fun > B.buildWith(json: JsonObject?, builder: B.(JsonObject) -> Unit): M? { - json ?: return null - builder(json) - @Suppress("UNCHECKED_CAST") - return build() as M -} - -inline fun > B.buildWithDocument(json: JsonObject?, builder: B.(JsonObject) -> Unit): M? { - val value = json?.getObject(KEY_VALUE) ?: return null - builder(value) - @Suppress("UNCHECKED_CAST") - return build() as M -} diff --git a/common/src/main/kotlin/de/hpi/cloud/common/utils/protobuf/Date.kt b/common/src/main/kotlin/de/hpi/cloud/common/utils/protobuf/Date.kt deleted file mode 100644 index 17cc75e..0000000 --- a/common/src/main/kotlin/de/hpi/cloud/common/utils/protobuf/Date.kt +++ /dev/null @@ -1,41 +0,0 @@ -package de.hpi.cloud.common.utils.protobuf - -import com.couchbase.client.java.document.json.JsonObject -import com.google.type.Date -import de.hpi.cloud.common.utils.couchbase.getNestedObject -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter -import java.time.format.DateTimeParseException - -@Deprecated("Use JsonObject.getDateUsingIsoFormat(name) instead.", ReplaceWith("getDateUsingIsoFormat(name)")) -fun JsonObject.getDateUsingMillis(name: String): Date? { - val millis = getNestedObject(name)?.getLong(TIMESTAMP_MILLIS) ?: return null - val instant = Instant.ofEpochMilli(millis) - return LocalDate.ofInstant(instant, ZoneOffset.UTC).toProtobufDate() -} - -fun JsonObject.getDateUsingIsoFormat(name: String) = getLocalDate(name)?.toProtobufDate() - -fun JsonObject.getLocalDate(name: String): LocalDate? { - return try { - LocalDate.parse( - getString(name) ?: return null, - DateTimeFormatter.ISO_DATE - ) - } catch (ex: DateTimeParseException) { - null - } -} - -fun LocalDate.toProtobufDate(): Date = Date.newBuilder() - .setYear(year) - .setMonth(monthValue) - .setDay(dayOfMonth) - .build() - -@Deprecated("Use Date.toIsoString() instead.", ReplaceWith("toIsoString()")) -fun Date.toQueryString() = toIsoString() - -fun Date.toIsoString() = String.format("%04d-%02d-%02d", year, month, day) diff --git a/common/src/main/kotlin/de/hpi/cloud/common/utils/protobuf/Money.kt b/common/src/main/kotlin/de/hpi/cloud/common/utils/protobuf/Money.kt deleted file mode 100644 index 7cb9bd4..0000000 --- a/common/src/main/kotlin/de/hpi/cloud/common/utils/protobuf/Money.kt +++ /dev/null @@ -1,33 +0,0 @@ -package de.hpi.cloud.common.utils.protobuf - -import com.couchbase.client.java.document.json.JsonObject -import com.google.type.Money -import de.hpi.cloud.common.utils.couchbase.getNestedObject -import de.hpi.cloud.common.utils.grpc.buildWith - -private const val MONEY_CURRENCY_CODE = "currencyCode" -private const val MONEY_UNITS = "units" -private const val MONEY_NANOS = "nanos" - - -fun Number.money(currencyCode: String): Money = Money.newBuilder() - .setCurrencyCode(currencyCode) - .setUnits(toLong()) - .setNanos(((toDouble() % 1) * 1_000_000_000).toInt()) - .build() - -fun Number.euros() = money("EUR") - -fun JsonObject.getMoney(name: String): Money? { - return Money.newBuilder().buildWith(getNestedObject(name)) { - currencyCode = it.getString(MONEY_CURRENCY_CODE) ?: return null - units = it.getLong(MONEY_UNITS) ?: return null - nanos = it.getInt(MONEY_NANOS) ?: return null - } -} - -fun Money.toDbMap() = mapOf( - MONEY_CURRENCY_CODE to currencyCode, - MONEY_UNITS to units, - MONEY_NANOS to nanos -) \ No newline at end of file diff --git a/common/src/main/kotlin/de/hpi/cloud/common/utils/protobuf/Timestamp.kt b/common/src/main/kotlin/de/hpi/cloud/common/utils/protobuf/Timestamp.kt deleted file mode 100644 index 119213c..0000000 --- a/common/src/main/kotlin/de/hpi/cloud/common/utils/protobuf/Timestamp.kt +++ /dev/null @@ -1,38 +0,0 @@ -package de.hpi.cloud.common.utils.protobuf - -import com.couchbase.client.java.document.json.JsonObject -import com.google.protobuf.Timestamp -import de.hpi.cloud.common.utils.couchbase.getNestedObject -import de.hpi.cloud.common.utils.grpc.buildWith -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.ZoneOffset - -const val TIMESTAMP_MILLIS = "millis" -const val TIMESTAMP_NANOS = "nanos" - -fun timestampNow() = LocalDateTime.now().toTimestamp() -fun timestampFromSeconds(seconds: Long, nanos: Int = 0): Timestamp = Timestamp.newBuilder() - .setSeconds(seconds) - .setNanos(nanos) - .build() - -fun timestampFromMillis(millis: Long, nanos: Int = 0) = - timestampFromSeconds(millis / 1000, (millis % 1000).toInt() * 1000000 + nanos) - -fun JsonObject.getTimestamp(name: String): Timestamp? { - return Timestamp.newBuilder().buildWith(getNestedObject(name)) { - val dbMillis = it.getLong(TIMESTAMP_MILLIS) ?: return null - val dbNanos = it.getInt(TIMESTAMP_NANOS) ?: return null - seconds = dbMillis / 1000 - nanos = (dbMillis % 1000).toInt() * 1000000 + dbNanos - } -} - -fun LocalDate.toTimestamp() = atStartOfDay().toTimestamp() -fun LocalDateTime.toTimestamp() = timestampFromSeconds(toEpochSecond(ZoneOffset.UTC)) - -fun Timestamp.toDbMap() = mapOf( - TIMESTAMP_MILLIS to (seconds * 1000 + nanos / 1000000), - TIMESTAMP_NANOS to nanos % 1000000 -) diff --git a/service-common/build.gradle b/service-common/build.gradle deleted file mode 100644 index 7870749..0000000 --- a/service-common/build.gradle +++ /dev/null @@ -1,8 +0,0 @@ -plugins { - id "kotlin" - id "application" -} - -dependencies { - api(project(":common")) -} diff --git a/service-common/src/main/kotlin/de/hpi/cloud/common/utils/I18n.kt b/service-common/src/main/kotlin/de/hpi/cloud/common/utils/I18n.kt deleted file mode 100644 index ff8d7fe..0000000 --- a/service-common/src/main/kotlin/de/hpi/cloud/common/utils/I18n.kt +++ /dev/null @@ -1,21 +0,0 @@ -package de.hpi.cloud.common.utils - -import com.couchbase.client.java.document.json.JsonObject -import com.google.protobuf.GeneratedMessageV3 -import de.hpi.cloud.common.Service -import de.hpi.cloud.common.utils.couchbase.getNestedObject -import de.hpi.cloud.common.utils.grpc.preferredLocales -import java.util.* - - -const val LANGUAGE_FALLBACK = "en" -fun JsonObject.getI18nString(name: String, request: GeneratedMessageV3): String? { - val obj = getNestedObject(name) - - val preferredLocales = Service.metadataForRequest(request).preferredLocales - val availableLocales = obj?.toMap()?.keys ?: return null - - return Locale.lookupTag(preferredLocales, availableLocales)?.let { obj.getString(it) } - ?: obj.getString(LANGUAGE_FALLBACK) - ?: (obj.toMap()?.values?.firstOrNull() as? String) -} diff --git a/service-common/src/main/kotlin/de/hpi/cloud/common/utils/protobuf/Image.kt b/service-common/src/main/kotlin/de/hpi/cloud/common/utils/protobuf/Image.kt deleted file mode 100644 index 3cca5ff..0000000 --- a/service-common/src/main/kotlin/de/hpi/cloud/common/utils/protobuf/Image.kt +++ /dev/null @@ -1,22 +0,0 @@ -package de.hpi.cloud.common.utils.protobuf - -import com.couchbase.client.java.document.json.JsonObject -import com.google.protobuf.GeneratedMessageV3 -import de.hpi.cloud.common.utils.getI18nString -import de.hpi.cloud.common.utils.couchbase.getNestedObject -import de.hpi.cloud.common.utils.grpc.buildWith -import de.hpi.cloud.common.v1test.Image - -enum class ImageSize { - ORIGINAL; - - val dbKey = toString().toLowerCase() -} - -fun JsonObject.getImage(name: String, request: GeneratedMessageV3, size: ImageSize = ImageSize.ORIGINAL): Image? { - return Image.newBuilder().buildWith(getNestedObject(name)) { - source = it.getObject("source")?.getString(size.dbKey) ?: return null - it.getI18nString("alt", request)?.let { a -> alt = a } - it.getDouble("aspectRatio")?.toFloat()?.let { a -> aspectRatio = a } - } -} diff --git a/settings.gradle b/settings.gradle index d7944c5..34eba01 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,6 +1,5 @@ rootProject.name = 'hpi-cloud' include 'common' -include 'service-common' include 'service-course', 'service-course-crawler' include 'service-crashreporting' include 'service-feedback'