From d5162e6a693ad219c3e0423b2b4211a1c6b663ce Mon Sep 17 00:00:00 2001 From: Joseph Ivie Date: Thu, 7 Nov 2024 08:58:55 -0700 Subject: [PATCH] Admin V2 AuthOption better toString Fix to KnownDeviceProofEndpoints's options endpoint to remove auth requirements Protobuf support UUID bytes support Better Partial support with toCondition and toModification, new partialOf syntax Fixed some VirtualInstance issues with serialization on default fields Better catch QueryParamWebSocketHandler loop Ktor Websocket path parsing changes --- server-core/build.gradle.kts | 2 +- .../lightningdb/FieldCollection.kt | 2 +- .../lightningserver/auth/AuthOption.kt | 1 + .../auth/proof/KnownDeviceProofEndpoints.kt | 2 +- .../lightningserver/db/ModelRestEndpoints.kt | 30 +- .../lightningserver/meta/MetaEndpoints.kt | 35 + .../serialization/ProtoBufOverrides.kt | 147 ++++ .../ProtoBufSchemaGeneratorAlt.kt | 663 ++++++++++++++++++ .../serialization/Serialization.kt | 13 +- .../lightningserver/typed/Documentable.kt | 5 +- .../lightningserver/typed/autodoc.kt | 26 +- .../websocket/MultiplexWebSocketHandler.kt | 1 + .../websocket/QueryParamWebSocketHandler.kt | 2 + .../serialization/SerializationTest.kt | 49 +- .../lightningserver/ktor/ktor.kt | 4 +- .../lightningserver/TestSettings.kt | 2 +- .../kotlin/com/lightningkite/UUID.android.kt | 36 + .../kotlin/com/lightningkite/UUID.kt | 2 + .../lightningdb/ConditionBuilder.kt | 17 +- .../lightningkite/lightningdb/Modification.kt | 1 + .../lightningdb/ModificationBuilder.kt | 15 +- .../lightningkite/serialization/Defaults.kt | 2 + .../lightningkite/serialization/Partial.kt | 55 +- .../serialization/PartialSerializer.kt | 2 +- .../serialization/SerializationRegistry.kt | 5 +- .../serialization/VirtualType.kt | 8 +- .../kotlin/com/lightningkite/UUIDTest.kt | 14 + .../lightningdb/SerializationTest.kt | 10 + .../kotlin/com/lightningkite/UUID.ios.kt | 19 + shared/src/jsMain/kotlin/UUID.js.kt | 50 ++ .../kotlin/com/lightningkite/UUID.jvm.kt | 36 + 31 files changed, 1212 insertions(+), 44 deletions(-) create mode 100644 server-core/src/main/kotlin/com/lightningkite/lightningserver/serialization/ProtoBufOverrides.kt create mode 100644 server-core/src/main/kotlin/com/lightningkite/lightningserver/serialization/ProtoBufSchemaGeneratorAlt.kt create mode 100644 shared/src/commonTest/kotlin/com/lightningkite/UUIDTest.kt diff --git a/server-core/build.gradle.kts b/server-core/build.gradle.kts index 175aaf502..f6199bfe9 100644 --- a/server-core/build.gradle.kts +++ b/server-core/build.gradle.kts @@ -29,7 +29,7 @@ dependencies { api(serverlibs.mongoBson) api(serverlibs.kBson) api(serverlibs.kaml) -// api(serverlibs.serializationProtobuf) + api(serverlibs.serializationProtobuf) api(serverlibs.kotlinReflect) implementation(serverlibs.bouncyCastleBcprov) implementation(serverlibs.bouncyCastleBcpkix) diff --git a/server-core/src/main/kotlin/com/lightningkite/lightningdb/FieldCollection.kt b/server-core/src/main/kotlin/com/lightningkite/lightningdb/FieldCollection.kt index bd1876171..dbdb34ee7 100644 --- a/server-core/src/main/kotlin/com/lightningkite/lightningdb/FieldCollection.kt +++ b/server-core/src/main/kotlin/com/lightningkite/lightningdb/FieldCollection.kt @@ -56,7 +56,7 @@ interface FieldCollection { limit = limit, maxQueryMs = maxQueryMs ).map { - Partial(it, fields) + partialOf(it, fields) } /** diff --git a/server-core/src/main/kotlin/com/lightningkite/lightningserver/auth/AuthOption.kt b/server-core/src/main/kotlin/com/lightningkite/lightningserver/auth/AuthOption.kt index 1bf796419..d1452ca72 100644 --- a/server-core/src/main/kotlin/com/lightningkite/lightningserver/auth/AuthOption.kt +++ b/server-core/src/main/kotlin/com/lightningkite/lightningserver/auth/AuthOption.kt @@ -14,6 +14,7 @@ class AuthOption( val limitationDescription: String? = null, val additionalRequirement: suspend (RequestAuth<*>) -> Boolean = { true } ) { + override fun toString(): String = "$type $scopes $maxAge" } data class AuthOptions?>(val options: Set) diff --git a/server-core/src/main/kotlin/com/lightningkite/lightningserver/auth/proof/KnownDeviceProofEndpoints.kt b/server-core/src/main/kotlin/com/lightningkite/lightningserver/auth/proof/KnownDeviceProofEndpoints.kt index e5d644bb5..0545aee7a 100644 --- a/server-core/src/main/kotlin/com/lightningkite/lightningserver/auth/proof/KnownDeviceProofEndpoints.kt +++ b/server-core/src/main/kotlin/com/lightningkite/lightningserver/auth/proof/KnownDeviceProofEndpoints.kt @@ -160,7 +160,7 @@ class KnownDeviceProofEndpoints( inputType = Unit.serializer(), outputType = KnownDeviceOptions.serializer(), description = "Gives information about how valuable working from a known device is and for how long it works.", - authOptions = anyAuthRoot, + authOptions = noAuth, errorCases = listOf(), examples = listOf(), implementation = { value: Unit -> diff --git a/server-core/src/main/kotlin/com/lightningkite/lightningserver/db/ModelRestEndpoints.kt b/server-core/src/main/kotlin/com/lightningkite/lightningserver/db/ModelRestEndpoints.kt index 1149ab1e3..be8cde580 100644 --- a/server-core/src/main/kotlin/com/lightningkite/lightningserver/db/ModelRestEndpoints.kt +++ b/server-core/src/main/kotlin/com/lightningkite/lightningserver/db/ModelRestEndpoints.kt @@ -178,7 +178,7 @@ open class ModelRestEndpoints?, T : HasId, ID : Comparable?, T : HasId, ID : Comparable -> + try { + info.collection(this) + .updateOneById(path1, input.toModification(info.serialization.serializer)) + .also { if (it.old == null && it.new == null) throw NotFoundException() } + .new!! + } catch (e: UniqueViolationException) { + throw BadRequestException(detail = "unique", message = e.key?.titleCase()?.let { "$it already exists" } ?: "Already exists", cause = e) + } + } + ) + val bulkDelete = post("bulk-delete").api( belongsToInterface = interfaceName, authOptions = info.authOptions, diff --git a/server-core/src/main/kotlin/com/lightningkite/lightningserver/meta/MetaEndpoints.kt b/server-core/src/main/kotlin/com/lightningkite/lightningserver/meta/MetaEndpoints.kt index 5bc810b23..6377f03df 100644 --- a/server-core/src/main/kotlin/com/lightningkite/lightningserver/meta/MetaEndpoints.kt +++ b/server-core/src/main/kotlin/com/lightningkite/lightningserver/meta/MetaEndpoints.kt @@ -73,6 +73,31 @@ class MetaEndpoints( }) } + private suspend fun openAdmin2(): HttpResponse { + val inject = buildJsonObject { + put("url", generalSettings().publicUrl) + } + val page = client.get("https://lsadmin.cs.lightningkite.com").bodyAsText() + .let { original -> + (original.substringBeforeLast("") + """ + + + """.trimIndent() + original.substringAfterLast("")) + } + .let { original -> + (original.substringBeforeLast("") + """ + + + """.trimIndent() + original.substringAfterLast("")) + } + return HttpResponse.html(content = page, headers = { + set( + "Content-Security-Policy", + "script-src 'unsafe-eval' ${generalSettings().publicUrl}/ https://lsadmin.cs.lightningkite.com/" + ) + }) + } + val bulk = path("bulk").bulkRequestEndpoint() val admin = path("admin/").get.handler { @@ -84,6 +109,15 @@ class MetaEndpoints( else openAdmin(it) } + val admin2 = path("admin2/").get.handler { + openAdmin2() + } + val admin2Resources = path("admin2/{...}").get.handler { + if (it.wildcard?.contains(".") == true) + HttpResponse.pathMovedOld("https://lsadmin.cs.lightningkite.com/${it.wildcard}") + else + openAdmin2() + } val schema = path("schema").get.handler { HttpResponse( body = HttpContent.Text( @@ -265,6 +299,7 @@ class MetaEndpoints( health.route.endpoint, isOnline, admin, + admin2, openApi, openApiJson, schema, diff --git a/server-core/src/main/kotlin/com/lightningkite/lightningserver/serialization/ProtoBufOverrides.kt b/server-core/src/main/kotlin/com/lightningkite/lightningserver/serialization/ProtoBufOverrides.kt new file mode 100644 index 000000000..386e7aaa3 --- /dev/null +++ b/server-core/src/main/kotlin/com/lightningkite/lightningserver/serialization/ProtoBufOverrides.kt @@ -0,0 +1,147 @@ +package com.lightningkite.lightningserver.serialization + +import com.lightningkite.UUID +import com.lightningkite.lightningserver.typed.uncontextualize +import com.lightningkite.serialization.DurationMsSerializer +import com.lightningkite.serialization.listElement +import com.lightningkite.serialization.mapKeyElement +import com.lightningkite.serialization.mapValueElement +import kotlinx.datetime.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.protobuf.ProtoIntegerType +import kotlinx.serialization.protobuf.ProtoNumber +import kotlinx.serialization.protobuf.ProtoType +import kotlin.time.Duration + +val ProtoBufOverrides = SerializersModule { + contextual(Duration::class, DurationMsSerializer) + contextual(UUID::class, UUIDByteArraySerializer) + contextual(Instant::class, InstantLongSerializer) +} + +object UUIDByteArraySerializer : KSerializer { + val defer = ByteArraySerializer() + override val descriptor: SerialDescriptor = SerialDescriptor("com.lightningkite.UUID", defer.descriptor) + + override fun deserialize(decoder: Decoder): UUID { + return UUID.parse(decoder.decodeSerializableValue(defer)) + } + + override fun serialize(encoder: Encoder, value: UUID) { + encoder.encodeSerializableValue(defer, value.toBytes()) + } +} + +//class ProtobufSchema(val module: SerializersModule, val packageName: String) { +// val aliases = HashMap() +// val messages = HashMap() +// val enums = HashMap() +// +// data class PMessage( +// val fields: List +// ) +// +// data class PAlias( +// val target: String, +// ) +// +// data class PField( +// val name: String, +// val type: PType, +// val optional: Boolean, +// val number: Int, +// ) +// +// data class PType( +// val name: String, +// val repeated: Boolean = false, +// val nullable: Boolean = false, +// ) +// +// data class PEnum( +// val entries: List> +// ) +// +// val typesHandled = HashSet() +// +// fun KSerializer<*>.protobufType(annotations: List = listOf()): PType { +// val serializer = this +// return when(serializer.descriptor.kind) { +// SerialKind.CONTEXTUAL -> (Serialization.json.serializersModule.getContextual( +// serializer.descriptor.capturedKClass ?: throw IllegalStateException("No captured KClass found for ${serializer.descriptor}") +// ) ?: throw IllegalStateException("No contextual serializer found for ${serializer.descriptor.capturedKClass!!.qualifiedName}")).protobufType(annotations) +// PrimitiveKind.BOOLEAN -> PType("bool", nullable = serializer.descriptor.isNullable) +// PrimitiveKind.BYTE, +// PrimitiveKind.CHAR, +// PrimitiveKind.SHORT, +// PrimitiveKind.INT -> { +// val integerType = annotations.filterIsInstance().firstOrNull()?.type ?: ProtoIntegerType.DEFAULT +// when (integerType) { +// ProtoIntegerType.DEFAULT -> PType("int32", nullable = serializer.descriptor.isNullable) +// ProtoIntegerType.SIGNED -> PType("sint32", nullable = serializer.descriptor.isNullable) +// ProtoIntegerType.FIXED -> PType("fixed32", nullable = serializer.descriptor.isNullable) +// } +// } +// PrimitiveKind.LONG -> { +// val integerType = annotations.filterIsInstance().firstOrNull()?.type ?: ProtoIntegerType.DEFAULT +// when (integerType) { +// ProtoIntegerType.DEFAULT -> PType("int64", nullable = serializer.descriptor.isNullable) +// ProtoIntegerType.SIGNED -> PType("sint64", nullable = serializer.descriptor.isNullable) +// ProtoIntegerType.FIXED -> PType("fixed64", nullable = serializer.descriptor.isNullable) +// } +// } +// PrimitiveKind.FLOAT -> PType("float", nullable = serializer.descriptor.isNullable) +// PrimitiveKind.DOUBLE -> PType("double", nullable = serializer.descriptor.isNullable) +// PrimitiveKind.STRING -> PType("string", nullable = serializer.descriptor.isNullable) +// StructureKind.LIST -> { +// val element = serializer.listElement()!! +// is(element.descriptor.kind == PrimitiveKind.BYTE) { +// PType("bytes", nullable = serializer.descriptor.isNullable) +// } else { +// element.protobufType(annotations).copy(repeated = true) +// } +// serializer.listElement()?.let { this += it } +// } +// StructureKind.MAP -> { +// serializer.mapKeyElement()?.let { this += it } +// serializer.mapValueElement()?.let { this += it } +// } +// StructureKind.OBJECT -> {} +// StructureKind.CLASS -> { +// val name = descriptor.serialName.corrected() +// if(!typesHandled.add(name)) return name +// messages[name] = PEnum((0.. +// val name = serializer.descriptor.getElementName(index).corrected() +// name to (serializer.descriptor.getElementAnnotations(index).filterIsInstance().singleOrNull()?.number ?: index) +// }) +// name +// } +// SerialKind.ENUM -> { +// val name = descriptor.serialName.corrected() +// if(!typesHandled.add(name)) return name +// enums[name] = PEnum((0.. +// val name = serializer.descriptor.getElementName(index).corrected() +// name to (serializer.descriptor.getElementAnnotations(index).filterIsInstance().singleOrNull()?.number ?: index) +// }) +// name +// } +// PolymorphicKind.SEALED -> throw NotImplementedError() +// PolymorphicKind.OPEN -> throw NotImplementedError() +// } +// } +// +// fun String.corrected(): String { +// val validChars = filter { it.isLetterOrDigit() || it == '_' } +// if (validChars.isEmpty()) throw IllegalArgumentException("Can not correct name '$this' for ProtoBuf schema") +// if (validChars[0] == '_') { +// if (validChars.length == 1) +// throw IllegalArgumentException("Can not correct name '$this' for ProtoBuf schema") +// return validChars.drop(1) +// } +// } +//} diff --git a/server-core/src/main/kotlin/com/lightningkite/lightningserver/serialization/ProtoBufSchemaGeneratorAlt.kt b/server-core/src/main/kotlin/com/lightningkite/lightningserver/serialization/ProtoBufSchemaGeneratorAlt.kt new file mode 100644 index 000000000..4dba6a49f --- /dev/null +++ b/server-core/src/main/kotlin/com/lightningkite/lightningserver/serialization/ProtoBufSchemaGeneratorAlt.kt @@ -0,0 +1,663 @@ +@file:OptIn(ExperimentalSerializationApi::class, InternalSerializationApi::class) + +package com.lightningkite.lightningserver.serialization + +import com.lightningkite.lightningserver.typed.subAndChildSerializers +import com.lightningkite.lightningserver.typed.subSerializers +import kotlinx.serialization.* +import kotlinx.serialization.builtins.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.protobuf.* +import kotlinx.serialization.protobuf.internal.* + +val ProtoBuf.schema get() = ProtoBufSchemaGeneratorAlt(this) + +/** + * Experimental generator of ProtoBuf schema that is compatible with [serializable][Serializable] Kotlin classes + * and data encoded and decoded by [ProtoBuf] format. + * + * The schema is generated based on provided [SerialDescriptor] and is compatible with proto2 schema definition. + * An arbitrary Kotlin class represent much wider object domain than the ProtoBuf specification, thus schema generator + * has the following list of restrictions: + * + * * Serial name of the class and all its fields should be a valid Proto [identifier](https://developers.google.com/protocol-buffers/docs/reference/proto2-spec) + * * Nullable values are allowed only for Kotlin [nullable][SerialDescriptor.isNullable] types, but not [optional][SerialDescriptor.isElementOptional] + * in order to properly distinguish "default" and "absent" values. + * * The name of the type without the package directive uniquely identifies the proto message type and two or more fields with the same serial name + * are considered to have the same type. Schema generator allows to specify a separate package directive for the pack of classes in order + * to mitigate this limitation. + * * Nested collections, e.g. `List>` are represented using the artificial wrapper message in order to distinguish + * repeated fields boundaries. + * * Default Kotlin values are not representable in proto schema. A special commentary is generated for properties with default values. + * * Empty nullable collections are not supported by the generated schema and will be prohibited in [ProtoBuf] as well + * due to their ambiguous nature. + * + * Temporary restrictions: + * * [Contextual] data is represented as as `bytes` type + * * [Polymorphic] data is represented as a artificial `KotlinxSerializationPolymorphic` message. + * + * Other types are mapped according to their specification: primitives as primitives, lists as 'repeated' fields and + * maps as 'repeated' map entries. + * + * The name of messages and enums is extracted from [SerialDescriptor.serialName] in [SerialDescriptor] without the package directive, + * as substring after the last dot character, the `'?'` character is also removed if it is present at the end of the string. + */ +@ExperimentalSerializationApi +public class ProtoBufSchemaGeneratorAlt(val protoBuf: ProtoBuf) { + + private val reverser = HashMap>() + + /** + * Generate text of protocol buffers schema version 2 for the given [rootDescriptor]. + * The resulting schema will contain all types referred by [rootDescriptor]. + * + * [packageName] define common protobuf package for all messages and enum in the schema, it may contain `'a'`..`'z'` + * letters in upper and lower case, decimal digits, `'.'` or `'_'` chars, but must be started only by a letter and + * not finished by a dot. + * + * [options] define values for protobuf options. Option value (map value) is an any string, option name (map key) + * should be the same format as [packageName]. + * + * The method throws [IllegalArgumentException] if any of the restrictions imposed by [ProtoBufSchemaGenerator] is violated. + */ + @ExperimentalSerializationApi + public fun generateSchemaText( + rootSerializer: KSerializer<*>, + packageName: String? = null, + options: Map = emptyMap() + ): String = generateSchemaText(listOf(rootSerializer), packageName, options) + + /** + * Generate text of protocol buffers schema version 2 for the given serializable [descriptors]. + * [packageName] define common protobuf package for all messages and enum in the schema, it may contain `'a'`..`'z'` + * letters in upper and lower case, decimal digits, `'.'` or `'_'` chars, but started only from a letter and + * not finished by dot. + * + * [options] define values for protobuf options. Option value (map value) is an any string, option name (map key) + * should be the same format as [packageName]. + * + * The method throws [IllegalArgumentException] if any of the restrictions imposed by [ProtoBufSchemaGenerator] is violated. + */ + @ExperimentalSerializationApi + public fun generateSchemaText( + serializers: List>, + packageName: String? = null, + options: Map = emptyMap() + ): String { + fun KSerializer<*>.prepReverses() { + reverser[descriptor] = this + subAndChildSerializers().forEach { it.prepReverses() } + } + serializers.forEach { it.prepReverses() } + val packageNameCorrected = + packageName?.let { p -> p.checkIsValidFullIdentifier { "Incorrect protobuf package name '$it'" } } + checkDoubles(serializers.map { it.descriptor }) + val builder = StringBuilder() + builder.generateProto2SchemaText(serializers.map { it.descriptor }, packageNameCorrected, options) + return builder.toString() + } + + private fun checkDoubles(descriptors: List) { + val rootTypesNames = mutableSetOf() + val duplicates = mutableListOf() + + descriptors.map { it.messageOrEnumName }.forEach { + if (!rootTypesNames.add(it)) { + duplicates += it + } + } + if (duplicates.isNotEmpty()) { + throw IllegalArgumentException("Serial names of the following types are duplicated: $duplicates") + } + } + + private fun StringBuilder.generateProto2SchemaText( + descriptors: List, + packageName: String?, + options: Map + ) { + appendLine("""syntax = "proto2";""").appendLine() + + packageName?.let { append("package ").append(it).appendLine(';') } + + for ((optionName, optionValue) in options) { + val safeOptionName = removeLineBreaks(optionName) + val safeOptionValue = removeLineBreaks(optionValue) + val safeOptionNameCorrected = safeOptionName.checkIsValidFullIdentifier { "Invalid option name '$it'" } + append("option ").append(safeOptionNameCorrected).append(" = \"").append(safeOptionValue).appendLine("\";") + } + + val generatedTypes = mutableSetOf() + val queue = ArrayDeque() + descriptors.map { TypeDefinition(it) }.forEach { queue.add(it) } + + while (queue.isNotEmpty()) { + val type = queue.removeFirst() + val descriptor = type.descriptor + val name = descriptor.messageOrEnumName + if (!generatedTypes.add(name)) { + continue + } + + appendLine() + when { + descriptor.isProtobufMessage -> queue.addAll(generateMessage(type)) + descriptor.isProtobufEnum -> generateEnum(type) + else -> throw IllegalStateException( + "Unrecognized custom type with serial name " + + "'${descriptor.serialName}' and kind '${descriptor.kind}'" + ) + } + } + } + + private fun StringBuilder.generateMessage(messageType: TypeDefinition): List { + val messageDescriptor = messageType.descriptor + val messageName: String + if (messageType.isSynthetic) { + append("// This message was generated to support ").append(messageType.ability) + .appendLine(" and does not present in Kotlin.") + + messageName = messageDescriptor.serialName + if (messageType.containingMessageName != null) { + append("// Containing message '").append(messageType.containingMessageName).append("', field '") + .append(messageType.fieldName).appendLine('\'') + } + } else { + messageName = messageDescriptor.messageOrEnumName.checkIsValidIdentifier { + "Invalid name for the message in protobuf schema '${messageDescriptor.messageOrEnumName}'. " + + "Serial name of the class '${messageDescriptor.serialName}'" + } + val safeSerialName = removeLineBreaks(messageDescriptor.serialName) + if (safeSerialName != messageName) { + append("// serial name '").append(safeSerialName).appendLine('\'') + } + } + + append("message ").append(messageName).appendLine(" {") + + val usedNumbers: MutableSet = mutableSetOf() + val nestedTypes = mutableListOf() + generateMessageField(messageName, messageType, nestedTypes, usedNumbers) + appendLine('}') + + return nestedTypes + } + + private fun StringBuilder.generateMessageField( + messageName: String, + parentType: TypeDefinition, + nestedTypes: MutableList, + usedNumbers: MutableSet, + counts: Int = parentType.descriptor.elementsCount, + getAnnotations: (Int) -> List = { parentType.descriptor.getElementAnnotations(it) }, + getChildType: (Int) -> TypeDefinition = { + parentType.descriptor.getElementDescriptor(it).decontextualize.let(::TypeDefinition) + }, + getChildNumber: (Int) -> Int = { + parentType.descriptor.getElementAnnotations(it).filterIsInstance().singleOrNull()?.number + ?: (it + 1) + }, + getChildName: (Int) -> String = { parentType.descriptor.getElementName(it) }, + inOneOfStruct: Boolean = false, + ) { + val messageDescriptor = parentType.descriptor + for (index in 0 until counts) { + var fieldName = getChildName(index) + fieldName = fieldName.checkIsValidIdentifier { + "Invalid name of the field '$fieldName' in ${if (inOneOfStruct) "oneof" else ""} message '$messageName' for class with serial " + + "name '${messageDescriptor.serialName}'" + } + + val fieldType = getChildType(index) + val fieldDescriptor = fieldType.descriptor + + val number = getChildNumber(index) + if (messageDescriptor.isChildOneOfMessage(index)) { + require(!inOneOfStruct) { + "Cannot have nested oneof in oneof struct: ${messageName}.$fieldName" + } + val subDescriptor = fieldDescriptor.getElementDescriptor(1).decontextualize.elementDescriptors.map { it.decontextualize } + append(" ").append("oneof").append(' ').append(fieldName).appendLine(" {") + subDescriptor.forEach { desc -> + require(desc.elementsCount == 1) { + "Implementation of oneOf type ${desc.serialName} should contain only 1 element, but get ${desc.elementsCount}" + } + generateMessageField( + messageName = messageName, + parentType = TypeDefinition(desc), + nestedTypes = nestedTypes, + usedNumbers = usedNumbers, + counts = desc.elementsCount, + getAnnotations = { desc.annotations }, + getChildType = { desc.elementDescriptors.single().decontextualize.let(::TypeDefinition) }, + getChildNumber = { + desc.getElementAnnotations(0).filterIsInstance().singleOrNull()?.number + ?: (it + 1) + }, + getChildName = { desc.getElementName(0) }, + inOneOfStruct = true, + ) + } + appendLine(" }") + } else { + val annotations = getAnnotations(index) + + val isList = fieldDescriptor.isProtobufRepeated + + nestedTypes += when { + fieldDescriptor.isProtobufNamedType -> generateNamedType( + fieldDescriptor = messageDescriptor.getElementDescriptor(index).decontextualize, + annotations = messageDescriptor.getElementAnnotations(index), + isSealedPolymorphic = messageDescriptor.isSealedPolymorphic && index == 1, + isOptional = messageDescriptor.isElementOptional(index), + inOneOfStruct = inOneOfStruct, + indent = if (inOneOfStruct) 2 else 1, + ) + + isList -> generateListType(parentType, index) + fieldDescriptor.isProtobufMap -> generateMapType(parentType, index) + else -> throw IllegalStateException( + "Unprocessed message field type with serial name " + + "'${fieldDescriptor.serialName}' and kind '${fieldDescriptor.kind}'" + ) + } + if (!usedNumbers.add(number)) { + throw IllegalArgumentException("Field number $number is repeated in the class with serial name ${messageDescriptor.serialName}") + } + + append(' ').append(fieldName).append(" = ").append(number) + + val isPackRequested = annotations.filterIsInstance().singleOrNull() != null + + when { + !isPackRequested || + !isList || // ignore as packed only meaningful on repeated types + !fieldDescriptor.getElementDescriptor(0).decontextualize.isPackable // Ignore if the type is not allowed to be packed + -> appendLine(';') + + else -> appendLine(" [packed=true];") + } + } + } + } + + private fun StringBuilder.generateNamedType( + fieldDescriptor: SerialDescriptor, + annotations: List, + isSealedPolymorphic: Boolean, + isOptional: Boolean, + inOneOfStruct: Boolean = false, + indent: Int = 1, + ): List { + var unwrappedFieldDescriptor = fieldDescriptor + while (unwrappedFieldDescriptor.isInline) { + unwrappedFieldDescriptor = unwrappedFieldDescriptor.getElementDescriptor(0).decontextualize + } + + val nestedTypes: List + val typeName: String = when { + isSealedPolymorphic -> { + append(" ".repeat(indent * 2)).appendLine("// decoded as message with one of these types:") + nestedTypes = unwrappedFieldDescriptor.elementDescriptors.map { TypeDefinition(it.decontextualize) }.toList() + nestedTypes.forEachIndexed { _, childType -> + append(" ".repeat(indent * 2)).append("// message ") + .append(childType.descriptor.messageOrEnumName).append(", serial name '") + .append(removeLineBreaks(childType.descriptor.serialName)).appendLine('\'') + } + unwrappedFieldDescriptor.scalarTypeName() + } + + unwrappedFieldDescriptor.isProtobufScalar -> { + nestedTypes = emptyList() + unwrappedFieldDescriptor.scalarTypeName(annotations) + } + + unwrappedFieldDescriptor.isOpenPolymorphic -> { + nestedTypes = listOf(SyntheticPolymorphicType) + SyntheticPolymorphicType.descriptor.serialName + } + + else -> { + // enum or regular message + nestedTypes = listOf(TypeDefinition(unwrappedFieldDescriptor)) + unwrappedFieldDescriptor.messageOrEnumName + } + } + +// if (isOptional) { +// append(" ".repeat(indent * 2)).appendLine("// WARNING: a default value decoded when value is missing") +// } + val optional = fieldDescriptor.isNullable || isOptional + + append(" ".repeat(indent * 2)).append( + when { + inOneOfStruct -> "" + optional -> "optional " + else -> "required " + } + ).append(typeName) + + return nestedTypes + } + + private fun StringBuilder.generateMapType(messageType: TypeDefinition, index: Int): List { + val messageDescriptor = messageType.descriptor + val mapDescriptor = messageDescriptor.getElementDescriptor(index).decontextualize + val originalMapValueDescriptor = mapDescriptor.getElementDescriptor(1).decontextualize + val valueType = if (originalMapValueDescriptor.isProtobufCollection) { + createNestedCollectionType(messageType, index, originalMapValueDescriptor, "nested collection in map value") + } else { + TypeDefinition(originalMapValueDescriptor) + } + val valueDescriptor = valueType.descriptor + + if (originalMapValueDescriptor.isNullable) { + appendLine(" // WARNING: nullable map values can not be represented in protobuf") + } + generateCollectionAbsenceComment(messageDescriptor, mapDescriptor, index) + + val keyTypeName = mapDescriptor.getElementDescriptor(0).decontextualize.scalarTypeName(mapDescriptor.getElementAnnotations(0)) + val valueTypeName = valueDescriptor.protobufTypeName(mapDescriptor.getElementAnnotations(1)) + append(" map<").append(keyTypeName).append(", ").append(valueTypeName).append(">") + + return if (valueDescriptor.isProtobufMessageOrEnum) { + listOf(valueType) + } else { + emptyList() + } + } + + private fun StringBuilder.generateListType(messageType: TypeDefinition, index: Int): List { + val messageDescriptor = messageType.descriptor + val collectionDescriptor = messageDescriptor.getElementDescriptor(index).decontextualize + val originalElementDescriptor = collectionDescriptor.getElementDescriptor(0).decontextualize + val elementType = if (collectionDescriptor.kind == StructureKind.LIST) { + if (originalElementDescriptor.isProtobufCollection) { + createNestedCollectionType(messageType, index, originalElementDescriptor, "nested collection in list") + } else { + TypeDefinition(originalElementDescriptor) + } + } else { + createLegacyMapType(messageType, index, "legacy map") + } + + val elementDescriptor = elementType.descriptor.decontextualize + + if (elementDescriptor.isNullable) { + appendLine(" // WARNING: nullable elements of collections can not be represented in protobuf") + } + generateCollectionAbsenceComment(messageDescriptor, collectionDescriptor, index) + + val typeName = elementDescriptor.protobufTypeName(messageDescriptor.getElementAnnotations(index)) + append(" repeated ").append(typeName) + + return if (elementDescriptor.isProtobufMessageOrEnum) { + listOf(elementType) + } else { + emptyList() + } + } + + private fun StringBuilder.generateEnum(enumType: TypeDefinition) { + val enumDescriptor = enumType.descriptor + var enumName = enumDescriptor.messageOrEnumName + enumName = enumName.checkIsValidIdentifier { + "Invalid name for the enum in protobuf schema '$enumName'. Serial name of the enum " + + "class '${enumDescriptor.serialName}'" + } + val safeSerialName = removeLineBreaks(enumDescriptor.serialName) + if (safeSerialName != enumName) { + append("// serial name '").append(safeSerialName).appendLine('\'') + } + + append("enum ").append(enumName).appendLine(" {") + + val usedNumbers: MutableSet = mutableSetOf() + val duplicatedNumbers: MutableSet = mutableSetOf() + enumDescriptor.elementDescriptors.forEachIndexed { index, element -> + var elementName = element.decontextualize.protobufEnumElementName + elementName = elementName.checkIsValidIdentifier { + "The enum element name '$elementName' is invalid in the " + + "protobuf schema. Serial name of the enum class '${enumDescriptor.serialName}'" + } + + val annotations = enumDescriptor.getElementAnnotations(index) + val number = annotations.filterIsInstance().singleOrNull()?.number ?: index + if (!usedNumbers.add(number)) { + duplicatedNumbers.add(number) + } + + append(" ").append(elementName).append(" = ").append(number).appendLine(';') + } + if (duplicatedNumbers.isNotEmpty()) { + throw IllegalArgumentException( + "The class with serial name ${enumDescriptor.serialName} has duplicate " + + "elements with numbers $duplicatedNumbers" + ) + } + + appendLine('}') + } + + private val SerialDescriptor.isOpenPolymorphic: Boolean + get() = kind == PolymorphicKind.OPEN + + private val SerialDescriptor.isSealedPolymorphic: Boolean + get() = kind == PolymorphicKind.SEALED + + private val SerialDescriptor.isProtobufNamedType: Boolean + get() = isProtobufMessageOrEnum || isProtobufScalar + + private val SerialDescriptor.isProtobufScalar: Boolean + get() = (kind is PrimitiveKind) + || (kind is StructureKind.LIST && getElementDescriptor(0).decontextualize.kind === PrimitiveKind.BYTE) + + private val SerialDescriptor.isProtobufMessageOrEnum: Boolean + get() = isProtobufMessage || isProtobufEnum + + private val SerialDescriptor.isProtobufMessage: Boolean + get() = kind == StructureKind.CLASS || kind == StructureKind.OBJECT || kind == PolymorphicKind.SEALED || kind == PolymorphicKind.OPEN + + private val SerialDescriptor.isProtobufCollection: Boolean + get() = isProtobufRepeated || isProtobufMap + + private val SerialDescriptor.isProtobufRepeated: Boolean + get() = (kind == StructureKind.LIST && getElementDescriptor(0).decontextualize.kind != PrimitiveKind.BYTE) + || (kind == StructureKind.MAP && !getElementDescriptor(0).decontextualize.isValidMapKey) + + private val SerialDescriptor.isProtobufMap: Boolean + get() = kind == StructureKind.MAP && getElementDescriptor(0).decontextualize.isValidMapKey + + private val SerialDescriptor.isProtobufEnum: Boolean + get() = kind == SerialKind.ENUM + + private val SerialDescriptor.isValidMapKey: Boolean + get() = kind == PrimitiveKind.INT || kind == PrimitiveKind.LONG || kind == PrimitiveKind.BOOLEAN || kind == PrimitiveKind.STRING + + + private val SerialDescriptor.messageOrEnumName: String + get() = (serialName.substringAfterLast('.', serialName)).removeSuffix("?") + reverser[this]?.subSerializers()?.joinToString("") { it.descriptor.messageOrEnumName } + + private val SerialDescriptor.decontextualize: SerialDescriptor get() { + return if(kind == SerialKind.CONTEXTUAL) { + protoBuf.serializersModule.getContextualDescriptor(this) ?: throw IllegalStateException("No contextual serializer available for ${serialName}, but it is required.") + } else this + } + + private fun SerialDescriptor.isChildOneOfMessage(index: Int): Boolean = + this.getElementDescriptor(index).decontextualize.isSealedPolymorphic && this.getElementAnnotations(index) + .any { it is ProtoOneOf } + + private fun SerialDescriptor.protobufTypeName(annotations: List = emptyList()): String { + return if (isProtobufScalar) { + scalarTypeName(annotations) + } else { + messageOrEnumName + } + } + + private val SerialDescriptor.protobufEnumElementName: String + get() = serialName.substringAfterLast('.', serialName) + + private fun SerialDescriptor.scalarTypeName(annotations: List = emptyList()): String { + val integerType = annotations.filterIsInstance().firstOrNull()?.type ?: ProtoIntegerType.DEFAULT + + if (kind == SerialKind.CONTEXTUAL) { + return "bytes" + } + + if (kind is StructureKind.LIST && getElementDescriptor(0).decontextualize.kind == PrimitiveKind.BYTE) { + return "bytes" + } + + return when (kind as PrimitiveKind) { + PrimitiveKind.BOOLEAN -> "bool" + PrimitiveKind.BYTE, PrimitiveKind.CHAR, PrimitiveKind.SHORT, PrimitiveKind.INT -> + when (integerType) { + ProtoIntegerType.DEFAULT -> "int32" + ProtoIntegerType.SIGNED -> "sint32" + ProtoIntegerType.FIXED -> "fixed32" + } + + PrimitiveKind.LONG -> + when (integerType) { + ProtoIntegerType.DEFAULT -> "int64" + ProtoIntegerType.SIGNED -> "sint64" + ProtoIntegerType.FIXED -> "fixed64" + } + + PrimitiveKind.FLOAT -> "float" + PrimitiveKind.DOUBLE -> "double" + PrimitiveKind.STRING -> "string" + } + } + + @SuppressAnimalSniffer // Boolean.hashCode(boolean) in compiler-generated hashCode implementation + private data class TypeDefinition( + val descriptor: SerialDescriptor, + val isSynthetic: Boolean = false, + val ability: String? = null, + val containingMessageName: String? = null, + val fieldName: String? = null + ) + + private val SyntheticPolymorphicType = TypeDefinition( + buildClassSerialDescriptor("KotlinxSerializationPolymorphic") { + element("type", PrimitiveSerialDescriptor("typeDescriptor", PrimitiveKind.STRING)) + element("value", buildSerialDescriptor("valueDescriptor", StructureKind.LIST) { + element("0", Byte.serializer().descriptor) + }) + }, + true, + "polymorphic types" + ) + + private class NotNullSerialDescriptor(val original: SerialDescriptor) : SerialDescriptor by original { + override val isNullable = false + } + + private val SerialDescriptor.notNull get() = NotNullSerialDescriptor(this) + + private fun StringBuilder.generateCollectionAbsenceComment( + messageDescriptor: SerialDescriptor, + collectionDescriptor: SerialDescriptor, + index: Int + ) { + if (!collectionDescriptor.isNullable && messageDescriptor.isElementOptional(index)) { + appendLine(" // WARNING: a default value decoded when value is missing") + } else if (collectionDescriptor.isNullable && !messageDescriptor.isElementOptional(index)) { + appendLine(" // WARNING: an empty collection decoded when a value is missing") + } else if (collectionDescriptor.isNullable && messageDescriptor.isElementOptional(index)) { + appendLine(" // WARNING: a default value decoded when value is missing") + } + } + + private fun createLegacyMapType( + messageType: TypeDefinition, + index: Int, + description: String + ): TypeDefinition { + val messageDescriptor = messageType.descriptor + val fieldDescriptor = messageDescriptor.getElementDescriptor(index).decontextualize + val fieldName = messageDescriptor.getElementName(index) + val messageName = messageDescriptor.messageOrEnumName + + val wrapperName = "${messageName}_${fieldName}" + val wrapperDescriptor = buildClassSerialDescriptor(wrapperName) { + element("key", fieldDescriptor.getElementDescriptor(0).decontextualize.notNull) + element("value", fieldDescriptor.getElementDescriptor(1).decontextualize.notNull) + } + + return TypeDefinition( + wrapperDescriptor, + true, + description, + messageType.containingMessageName ?: messageName, + messageType.fieldName ?: fieldName + ) + } + + private fun createNestedCollectionType( + messageType: TypeDefinition, + index: Int, + elementDescriptor: SerialDescriptor, + description: String + ): TypeDefinition { + val messageDescriptor = messageType.descriptor + val fieldName = messageDescriptor.getElementName(index) + val messageName = messageDescriptor.messageOrEnumName + + val wrapperName = "${messageName}_${fieldName}" + val wrapperDescriptor = buildClassSerialDescriptor(wrapperName) { + element("value", elementDescriptor.decontextualize.notNull.decontextualize) + } + + return TypeDefinition( + wrapperDescriptor, + true, + description, + messageType.containingMessageName ?: messageName, + messageType.fieldName ?: fieldName + ) + } + + private fun removeLineBreaks(text: String): String { + return text.replace('\n', ' ').replace('\r', ' ') + } + + private val IDENTIFIER_REGEX = Regex("[A-Za-z][A-Za-z0-9_]*") + + private fun String.checkIsValidFullIdentifier(messageSupplier: (String) -> String): String { + return split('.').joinToString(".") { it.checkIsValidIdentifier { messageSupplier(it) } } + } + + private fun String.checkIsValidIdentifier(messageSupplier: () -> String): String { + val validChars = filter { it.isLetterOrDigit() || it == '_' } + if (validChars.isEmpty()) throw IllegalArgumentException(messageSupplier.invoke()) + if (validChars[0] == '_') { + if (validChars.length == 1) + throw IllegalArgumentException(messageSupplier.invoke()) + return validChars.drop(1) + } + return validChars + } +} + +/** + * Suppresses Animal Sniffer plugin errors for certain methods. + * Such methods include references to Java 8 methods that are not + * available in Android API, but can be desugared by R8. + */ +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +internal annotation class SuppressAnimalSniffer + +internal val SerialDescriptor.isPackable: Boolean + @OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) + get() = when (kind) { + PrimitiveKind.STRING, + !is PrimitiveKind -> false + + else -> true + } diff --git a/server-core/src/main/kotlin/com/lightningkite/lightningserver/serialization/Serialization.kt b/server-core/src/main/kotlin/com/lightningkite/lightningserver/serialization/Serialization.kt index e39cb341d..628b3bdeb 100644 --- a/server-core/src/main/kotlin/com/lightningkite/lightningserver/serialization/Serialization.kt +++ b/server-core/src/main/kotlin/com/lightningkite/lightningserver/serialization/Serialization.kt @@ -34,6 +34,7 @@ import kotlinx.serialization.* import kotlinx.serialization.descriptors.StructureKind import java.util.* import com.lightningkite.UUID +import kotlinx.serialization.protobuf.ProtoBuf import kotlin.collections.HashMap /** @@ -136,9 +137,9 @@ abstract class Serialization { var formData: FormDataFormat by SetOnce { FormDataFormat(StringDeferringConfig(module, deferredFormat = json)) } -// var protobuf: ProtoBuf by SetOnce { -// ProtoBuf { this.serializersModule = module } -// } + var protobuf: ProtoBuf by SetOnce { + ProtoBuf { this.serializersModule = module.overwriteWith(ProtoBufOverrides) } + } interface HttpContentParser { val contentType: ContentType @@ -224,9 +225,9 @@ abstract class Serialization { fun enablePublicJavaData() { handler(BinaryFormatHandler({ javaData }, ContentType.Application.StructuredBytes)) } -// fun enablePublicProtobuf() { -// handler(BinaryFormatHandler({ protobuf }, ContentType.Application.ProtoBuf)) -// } + fun enablePublicProtobuf() { + handler(BinaryFormatHandler({ protobuf }, ContentType.Application.ProtoBuf)) + } fun enablePublicXml() { handler(StringFormatHandler({ xml }, ContentType.Text.Xml)) } diff --git a/server-core/src/main/kotlin/com/lightningkite/lightningserver/typed/Documentable.kt b/server-core/src/main/kotlin/com/lightningkite/lightningserver/typed/Documentable.kt index 43bf0b0dd..2dc8f8ba0 100644 --- a/server-core/src/main/kotlin/com/lightningkite/lightningserver/typed/Documentable.kt +++ b/server-core/src/main/kotlin/com/lightningkite/lightningserver/typed/Documentable.kt @@ -18,6 +18,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.SerialKind import kotlinx.serialization.descriptors.capturedKClass import kotlinx.serialization.internal.GeneratedSerializer +import kotlinx.serialization.modules.SerializersModule interface Documentable { val path: TypedServerPath @@ -95,9 +96,9 @@ internal fun KSerializer<*>.subAndChildSerializers(): Array> = nu ?: (this as? DataClassPathSerializer<*>)?.inner?.let { arrayOf(it) } ?: arrayOf() -internal fun KSerializer<*>.uncontextualize(): KSerializer<*> { +internal fun KSerializer<*>.uncontextualize(module: SerializersModule = Serialization.json.serializersModule): KSerializer<*> { return if (this.descriptor.kind == SerialKind.CONTEXTUAL) { - Serialization.json.serializersModule.getContextual( + module.getContextual( descriptor.capturedKClass ?: throw IllegalStateException("No captured KClass found for $descriptor") ) ?: throw IllegalStateException("No contextual serializer found for ${descriptor.capturedKClass!!.qualifiedName}") diff --git a/server-core/src/main/kotlin/com/lightningkite/lightningserver/typed/autodoc.kt b/server-core/src/main/kotlin/com/lightningkite/lightningserver/typed/autodoc.kt index b55966f73..b9b860b42 100644 --- a/server-core/src/main/kotlin/com/lightningkite/lightningserver/typed/autodoc.kt +++ b/server-core/src/main/kotlin/com/lightningkite/lightningserver/typed/autodoc.kt @@ -7,7 +7,9 @@ import com.lightningkite.lightningserver.core.ContentType import com.lightningkite.lightningserver.core.LightningServerDsl import com.lightningkite.lightningserver.core.ServerPath import com.lightningkite.lightningserver.http.* +import com.lightningkite.lightningserver.serialization.ProtoBufSchemaGeneratorAlt import com.lightningkite.lightningserver.serialization.Serialization +import com.lightningkite.lightningserver.serialization.schema import com.lightningkite.lightningserver.settings.generalSettings import kotlinx.html.* import kotlinx.serialization.ContextualSerializer @@ -18,10 +20,10 @@ import kotlinx.serialization.descriptors.SerialKind import kotlinx.serialization.descriptors.StructureKind import kotlinx.serialization.descriptors.elementNames import kotlinx.serialization.internal.GeneratedSerializer -//import kotlinx.serialization.protobuf.schema.ProtoBufSchemaGenerator import kotlinx.serialization.serializer import kotlin.reflect.KType import com.lightningkite.serialization.* +import kotlinx.serialization.protobuf.schema.ProtoBufSchemaGenerator @Deprecated( "Use apiDocs instead", @@ -56,17 +58,17 @@ fun ServerPath.apiDocs(packageName: String = "com.mypackage"): HttpEndpoint { ) ) } -// get("sdk.protobuf").handler { -// HttpResponse( -// HttpContent.Text( -// string = ProtoBufSchemaGenerator.generateSchemaText( -// Documentable.usedTypes.map { it.descriptor }, -// packageName = packageName -// ), -// type = ContentType.Application.ProtoBufDeclaration -// ) -// ) -// } + get("sdk.protobuf").handler { + HttpResponse( + HttpContent.Text( + string = Serialization.protobuf.schema.generateSchemaText( + Documentable.usedTypes.toList(), + packageName = packageName + ), + type = ContentType.Application.ProtoBufDeclaration + ) + ) + } return this.copy(after = ServerPath.Afterwards.TrailingSlash).get.handler { _ -> HttpResponse(body = HttpContent.html { head { title("${generalSettings().projectName} - Generated Documentation") } diff --git a/server-core/src/main/kotlin/com/lightningkite/lightningserver/websocket/MultiplexWebSocketHandler.kt b/server-core/src/main/kotlin/com/lightningkite/lightningserver/websocket/MultiplexWebSocketHandler.kt index b6d9f8520..7d65ad2ba 100644 --- a/server-core/src/main/kotlin/com/lightningkite/lightningserver/websocket/MultiplexWebSocketHandler.kt +++ b/server-core/src/main/kotlin/com/lightningkite/lightningserver/websocket/MultiplexWebSocketHandler.kt @@ -85,6 +85,7 @@ class MultiplexWebSocketHandler(val cache: () -> Cache) : WebSockets.Handler { ), timeToLive = 1.days ) + channels(event.id).set(setOf(), 8.hours) } override suspend fun message(event: WebSockets.MessageEvent) { diff --git a/server-core/src/main/kotlin/com/lightningkite/lightningserver/websocket/QueryParamWebSocketHandler.kt b/server-core/src/main/kotlin/com/lightningkite/lightningserver/websocket/QueryParamWebSocketHandler.kt index e34f5f5df..308406e49 100644 --- a/server-core/src/main/kotlin/com/lightningkite/lightningserver/websocket/QueryParamWebSocketHandler.kt +++ b/server-core/src/main/kotlin/com/lightningkite/lightningserver/websocket/QueryParamWebSocketHandler.kt @@ -4,6 +4,7 @@ import com.lightningkite.lightningserver.cache.Cache import com.lightningkite.lightningserver.cache.get import com.lightningkite.lightningserver.cache.set import com.lightningkite.lightningserver.core.ServerPath +import com.lightningkite.lightningserver.exceptions.BadRequestException import com.lightningkite.lightningserver.exceptions.NotFoundException import com.lightningkite.lightningserver.exceptions.report import com.lightningkite.lightningserver.metrics.Metrics @@ -15,6 +16,7 @@ class QueryParamWebSocketHandler(val cache: () -> Cache) : WebSockets.Handler { WebSockets.matcher.match(other) ?: throw NotFoundException("No web socket handler found for '$other'") val otherHandler = WebSockets.handlers[match.path] ?: throw NotFoundException("No web socket handler found for '$other'") + if(otherHandler == this) throw BadRequestException("No valid handler given; recursive path ${match.path} given") cache().set("${event.id}-path", match.path.toString()) val fixedQueryParameters = event.queryParameters.mapNotNull { if (it.first == "path") { diff --git a/server-core/src/test/kotlin/com/lightningkite/lightningserver/serialization/SerializationTest.kt b/server-core/src/test/kotlin/com/lightningkite/lightningserver/serialization/SerializationTest.kt index f869e4e98..ce3282a02 100644 --- a/server-core/src/test/kotlin/com/lightningkite/lightningserver/serialization/SerializationTest.kt +++ b/server-core/src/test/kotlin/com/lightningkite/lightningserver/serialization/SerializationTest.kt @@ -1,3 +1,4 @@ +@file:UseContextualSerialization(Instant::class, UUID::class) package com.lightningkite.lightningserver.serialization import com.lightningkite.DeferToContextualUuidSerializer @@ -10,6 +11,7 @@ import kotlinx.datetime.Instant import kotlin.time.Duration.Companion.milliseconds import com.lightningkite.UUID import com.lightningkite.lightningdb.* +import com.lightningkite.lightningserver.prepareModelsServerCoreTest import com.lightningkite.serialization.* import kotlinx.serialization.* import kotlinx.serialization.modules.EmptySerializersModule @@ -18,11 +20,12 @@ import kotlin.reflect.KClass import kotlin.test.assertIs import kotlin.test.assertIsNot +@GenerateDataClassPaths @Serializable data class BsonSerTest( val x: Int = 42, - @Contextual val y: Instant = now().roundTo(1.milliseconds), - @Contextual val z: UUID = uuid() + val y: Instant = now().roundTo(1.milliseconds), + val z: UUID = uuid() ) class SerializationTest { @@ -31,6 +34,48 @@ class SerializationTest { println(Serialization.bson.stringify(BsonSerTest.serializer(), v).toJson()) assertEquals(v, Serialization.bson.load(BsonSerTest.serializer(), Serialization.bson.dump(BsonSerTest.serializer(), v))) } + @OptIn(ExperimentalStdlibApi::class) + @Test fun protobuf() { + val v = BsonSerTest(x = -15) + val asBuffer = Serialization.protobuf.encodeToByteArray(BsonSerTest.serializer(), v) + println(Serialization.protobuf.schema.generateSchemaText(BsonSerTest.serializer(), "com.lightningkite.lightningserver.serialization")) + println(asBuffer.toHexString()) + assertEquals(v, Serialization.protobuf.decodeFromByteArray(BsonSerTest.serializer(), asBuffer)) + } + @OptIn(ExperimentalStdlibApi::class) + @Test fun protobufPartial() { + prepareModelsServerCoreTest() + val v = partialOf { + it.x assign 15 + it.y assign now().roundTo(1.milliseconds) + it.z assign uuid() + } + val s = PartialSerializer(BsonSerTest.serializer()) + val asBuffer = Serialization.protobuf.encodeToByteArray(s, v) + println(Serialization.protobuf.schema.generateSchemaText(s, "com.lightningkite.lightningserver.serialization")) + println(asBuffer.toHexString()) + assertEquals(v, Serialization.protobuf.decodeFromByteArray(s, asBuffer)) + } + @OptIn(ExperimentalStdlibApi::class) + @Test fun javaData() { + val v = BsonSerTest(x = -15) + val asBuffer = Serialization.javaData.encodeToByteArray(BsonSerTest.serializer(), v) + println(asBuffer.toHexString()) + assertEquals(v, Serialization.javaData.decodeFromByteArray(BsonSerTest.serializer(), asBuffer)) + } + @OptIn(ExperimentalStdlibApi::class) + @Test fun javaDataPartial() { + prepareModelsServerCoreTest() + val v = partialOf { + it.x assign 15 + it.y assign now().roundTo(1.milliseconds) + it.z assign uuid() + } + val s = PartialSerializer(BsonSerTest.serializer()) + val asBuffer = Serialization.javaData.encodeToByteArray(s, v) + println(asBuffer.toHexString()) + assertEquals(v, Serialization.javaData.decodeFromByteArray(s, asBuffer)) + } @Test fun contextual() { assertIs>(Serialization.module.contextualSerializerIfHandled()) assertIs>(Serialization.module.contextualSerializerIfHandled().nullElement()) diff --git a/server-ktor/src/main/kotlin/com/lightningkite/lightningserver/ktor/ktor.kt b/server-ktor/src/main/kotlin/com/lightningkite/lightningserver/ktor/ktor.kt index 48ccd309c..10c3784a5 100644 --- a/server-ktor/src/main/kotlin/com/lightningkite/lightningserver/ktor/ktor.kt +++ b/server-ktor/src/main/kotlin/com/lightningkite/lightningserver/ktor/ktor.kt @@ -4,6 +4,7 @@ import com.lightningkite.lightningserver.cache.* import com.lightningkite.lightningserver.core.ServerPath import com.lightningkite.lightningserver.engine.LocalEngine import com.lightningkite.lightningserver.engine.engine +import com.lightningkite.lightningserver.exceptions.BadRequestException import com.lightningkite.lightningserver.exceptions.exceptionSettings import com.lightningkite.lightningserver.http.* import com.lightningkite.lightningserver.http.HttpHeaders @@ -101,8 +102,7 @@ fun Application.lightningServer(pubSub: PubSub, cache: Cache) { val parts = HashMap() var wildcard: String? = null call.parameters.forEach { s, strings -> - if (strings.size > 1) wildcard = strings.joinToString("/") - parts[s] = strings.single() + parts[s] = strings.joinToString("/") } val id = ws.connect() val cache = LocalCache() diff --git a/server-testing/src/test/kotlin/com/lightningkite/lightningserver/TestSettings.kt b/server-testing/src/test/kotlin/com/lightningkite/lightningserver/TestSettings.kt index 13297c952..1297f88e2 100644 --- a/server-testing/src/test/kotlin/com/lightningkite/lightningserver/TestSettings.kt +++ b/server-testing/src/test/kotlin/com/lightningkite/lightningserver/TestSettings.kt @@ -59,7 +59,7 @@ object TestSettings: ServerPathGroup(ServerPath.root) { init { Serialization.enablePublicJavaData() -// Serialization.enablePublicProtobuf() + Serialization.enablePublicProtobuf() } val info = modelInfo( diff --git a/shared/src/androidMain/kotlin/com/lightningkite/UUID.android.kt b/shared/src/androidMain/kotlin/com/lightningkite/UUID.android.kt index 3ffee2336..b3f3207a1 100644 --- a/shared/src/androidMain/kotlin/com/lightningkite/UUID.android.kt +++ b/shared/src/androidMain/kotlin/com/lightningkite/UUID.android.kt @@ -15,9 +15,45 @@ actual data class UUID( else this.leastSignificantBits.compareTo(other.leastSignificantBits) } + actual fun toBytes(): ByteArray = byteArrayOf( + mostSignificantBits.shr(8 * 7).toByte(), + mostSignificantBits.shr(8 * 6).toByte(), + mostSignificantBits.shr(8 * 5).toByte(), + mostSignificantBits.shr(8 * 4).toByte(), + mostSignificantBits.shr(8 * 3).toByte(), + mostSignificantBits.shr(8 * 2).toByte(), + mostSignificantBits.shr(8 * 1).toByte(), + mostSignificantBits.shr(8 * 0).toByte(), + leastSignificantBits.shr(8 * 7).toByte(), + leastSignificantBits.shr(8 * 6).toByte(), + leastSignificantBits.shr(8 * 5).toByte(), + leastSignificantBits.shr(8 * 4).toByte(), + leastSignificantBits.shr(8 * 3).toByte(), + leastSignificantBits.shr(8 * 2).toByte(), + leastSignificantBits.shr(8 * 1).toByte(), + leastSignificantBits.shr(8 * 0).toByte(), + ) actual companion object { actual fun random(): UUID = java.util.UUID.randomUUID().let { UUID(it.mostSignificantBits.toULong(), it.leastSignificantBits.toULong()) } actual fun parse(uuidString: String): UUID = java.util.UUID.fromString(uuidString).let { UUID(it.mostSignificantBits.toULong(), it.leastSignificantBits.toULong()) } + actual fun parse(bytes: ByteArray): UUID = UUID( + bytes[0].toUByte().toULong().shl(8*7) or + bytes[1].toUByte().toULong().shl(8*6) or + bytes[2].toUByte().toULong().shl(8*5) or + bytes[3].toUByte().toULong().shl(8*4) or + bytes[4].toUByte().toULong().shl(8*3) or + bytes[5].toUByte().toULong().shl(8*2) or + bytes[6].toUByte().toULong().shl(8*1) or + bytes[7].toUByte().toULong().shl(8*0) , + bytes[8].toUByte().toULong().shl(8*7) or + bytes[9].toUByte().toULong().shl(8*6) or + bytes[10].toUByte().toULong().shl(8*5) or + bytes[11].toUByte().toULong().shl(8*4) or + bytes[12].toUByte().toULong().shl(8*3) or + bytes[13].toUByte().toULong().shl(8*2) or + bytes[14].toUByte().toULong().shl(8*1) or + bytes[15].toUByte().toULong().shl(8*0) , + ) } override fun toString(): String { diff --git a/shared/src/commonMain/kotlin/com/lightningkite/UUID.kt b/shared/src/commonMain/kotlin/com/lightningkite/UUID.kt index 85313cf24..44962b5e9 100644 --- a/shared/src/commonMain/kotlin/com/lightningkite/UUID.kt +++ b/shared/src/commonMain/kotlin/com/lightningkite/UUID.kt @@ -16,9 +16,11 @@ object DeferToContextualUuidSerializer: KSerializer by ContextualSerialize @Serializable(DeferToContextualUuidSerializer::class) expect class UUID: Comparable { override fun compareTo(other: UUID): Int + fun toBytes(): ByteArray companion object { fun random(): UUID fun parse(uuidString: String): UUID + fun parse(bytes: ByteArray): UUID } } @Deprecated("Use UUID.v4() instead", ReplaceWith("UUID.v4()", "com.lightningkite.UUID")) fun uuid(): UUID = UUID.random() diff --git a/shared/src/commonMain/kotlin/com/lightningkite/lightningdb/ConditionBuilder.kt b/shared/src/commonMain/kotlin/com/lightningkite/lightningdb/ConditionBuilder.kt index 2344d62af..3de8a64d1 100644 --- a/shared/src/commonMain/kotlin/com/lightningkite/lightningdb/ConditionBuilder.kt +++ b/shared/src/commonMain/kotlin/com/lightningkite/lightningdb/ConditionBuilder.kt @@ -6,11 +6,8 @@ import com.lightningkite.GeoCoordinate import com.lightningkite.IsRawString import com.lightningkite.Length import com.lightningkite.Length.Companion.miles -import com.lightningkite.serialization.serializerOrContextual -import com.lightningkite.serialization.DataClassPathSelf -import com.lightningkite.serialization.DataClassPath -import com.lightningkite.serialization.DataClassPathAccess -import com.lightningkite.serialization.DataClassPathNotNull +import com.lightningkite.serialization.* +import kotlinx.serialization.KSerializer import kotlinx.serialization.serializer import kotlin.js.JsName import kotlin.jvm.JvmName @@ -65,4 +62,12 @@ infix fun DataClassPath.notIn(values: List) = mapCondition(Condi @Deprecated("Size equals will be removed in the future in favor of something that detects empty specifically") @JsName("xDataClassPathListSizedEqual") @JvmName("listSizedEqual") infix fun DataClassPath>.sizesEquals(count: Int) = mapCondition(Condition.ListSizesEquals(count)) @Deprecated("Size equals will be removed in the future in favor of something that detects empty specifically") -@JsName("xDataClassPathSetSizedEqual") @JvmName("setSizedEqual") infix fun DataClassPath>.sizesEquals(count: Int) = mapCondition(Condition.SetSizesEquals(count)) \ No newline at end of file +@JsName("xDataClassPathSetSizedEqual") @JvmName("setSizedEqual") infix fun DataClassPath>.sizesEquals(count: Int) = mapCondition(Condition.SetSizesEquals(count)) + +fun DataClassPathWithValue.eq(): Condition = path.mapCondition(Condition.Equal(value)) +inline fun Partial.toCondition(): Condition = toCondition(serializer()) +fun Partial.toCondition(serializer: KSerializer): Condition { + val out = ArrayList>() + perPath(DataClassPathSelf(serializer)) { out += it.eq() } + return Condition.And(out) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lightningkite/lightningdb/Modification.kt b/shared/src/commonMain/kotlin/com/lightningkite/lightningdb/Modification.kt index c55d6c48f..36f6c2b82 100644 --- a/shared/src/commonMain/kotlin/com/lightningkite/lightningdb/Modification.kt +++ b/shared/src/commonMain/kotlin/com/lightningkite/lightningdb/Modification.kt @@ -30,6 +30,7 @@ sealed class Modification { override val isNothing: Boolean get() = modifications.all { it.isNothing } override fun invoke(on: T): T = modifications.fold(on) { item, mod -> mod(item) } + override fun toString(): String = modifications.joinToString("; ") { it.toString() } } @Serializable(ModificationIfNotNullSerializer::class) diff --git a/shared/src/commonMain/kotlin/com/lightningkite/lightningdb/ModificationBuilder.kt b/shared/src/commonMain/kotlin/com/lightningkite/lightningdb/ModificationBuilder.kt index 5a9d829f6..3202e8b4c 100644 --- a/shared/src/commonMain/kotlin/com/lightningkite/lightningdb/ModificationBuilder.kt +++ b/shared/src/commonMain/kotlin/com/lightningkite/lightningdb/ModificationBuilder.kt @@ -2,7 +2,12 @@ package com.lightningkite.lightningdb import com.lightningkite.serialization.DataClassPath import com.lightningkite.IsRawString +import com.lightningkite.serialization.DataClassPathSelf +import com.lightningkite.serialization.DataClassPathWithValue import kotlin.jvm.JvmName +import com.lightningkite.serialization.Partial +import kotlinx.serialization.KSerializer +import kotlinx.serialization.serializer inline fun modification(setup: ModificationBuilder.(DataClassPath) -> Unit): Modification { return ModificationBuilder().apply { @@ -200,4 +205,12 @@ class ModificationBuilder() { infix fun DataClassPath>.removeKeys(fields: Set) { modifications.add(mapModification(Modification.RemoveKeys(fields))) } -} \ No newline at end of file +} + +fun DataClassPathWithValue.modify(): Modification = path.mapModification(Modification.Assign(value)) +inline fun Partial.toModification(): Modification = toModification(serializer()) +fun Partial.toModification(serializer: KSerializer): Modification { + val out = ArrayList>() + perPath(DataClassPathSelf(serializer)) { out += it.modify() } + return Modification.Chain(out) +} diff --git a/shared/src/commonMain/kotlin/com/lightningkite/serialization/Defaults.kt b/shared/src/commonMain/kotlin/com/lightningkite/serialization/Defaults.kt index 917e3e71f..c168aa341 100644 --- a/shared/src/commonMain/kotlin/com/lightningkite/serialization/Defaults.kt +++ b/shared/src/commonMain/kotlin/com/lightningkite/serialization/Defaults.kt @@ -8,6 +8,7 @@ import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.CompositeDecoder import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import kotlin.time.Duration.Companion.seconds @@ -36,6 +37,7 @@ object DefaultDecoder : Decoder { } override var serializersModule: SerializersModule = ClientModule + val json by lazy { Json { serializersModule = DefaultDecoder.serializersModule } } override fun decodeBoolean() = false override fun decodeByte() = 0.toByte() override fun decodeChar() = ' ' diff --git a/shared/src/commonMain/kotlin/com/lightningkite/serialization/Partial.kt b/shared/src/commonMain/kotlin/com/lightningkite/serialization/Partial.kt index f537db95c..a739ef774 100644 --- a/shared/src/commonMain/kotlin/com/lightningkite/serialization/Partial.kt +++ b/shared/src/commonMain/kotlin/com/lightningkite/serialization/Partial.kt @@ -2,16 +2,45 @@ package com.lightningkite.serialization import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.StructureKind import kotlinx.serialization.serializer +import kotlin.jvm.JvmName +// TODO : Optimize for fully complete subobjects +// TODO : Better handle nullable objects for mod translation @Serializable(PartialSerializer::class) -data class Partial(val parts: MutableMap, Any?> = mutableMapOf()) { +data class Partial( + val parts: MutableMap, Any?> = mutableMapOf() +) { + @Deprecated("Use partialOf instead.") constructor(item: T, paths: Iterable>) : this() { paths.forEach { it.setMap(item, this) } } + @Deprecated("Use partialOf instead.") constructor(item: T, paths: Array>) : this() { paths.forEach { it.setMap(item, this) } } + fun total(serializer: KSerializer): T? = if(parts.keys.containsAll(serializer.serializableProperties!!.asList())) { + var out = serializer.default() + perPath(DataClassPathSelf(serializer)) { + out = it.set(out) + } + out + } else null + fun perPath(soFar: DataClassPath, action: (DataClassPathWithValue) -> Unit) { + for(part in parts) { + val p = DataClassPathAccess(soFar, part.key as SerializableProperty) + if(part.value is Partial<*> && part.key.serializer.let { it.nullElement() ?: it } !is PartialSerializer<*>) { + (part.value as Partial).perPath(p, action) + } else { + action(DataClassPathWithValue(p, part.value)) + } + } + } +} + +data class DataClassPathWithValue(val path: DataClassPath, val value: V) { + fun set(a: A): A = path.set(a, value) } @Suppress("UNCHECKED_CAST") @@ -34,7 +63,11 @@ class PartialBuilder(val parts: MutableMap, Any?> @Suppress("NOTHING_TO_INLINE") inline infix fun DataClassPath.assign(value: A) { if(this !is DataClassPathAccess<*, *, *>) throw IllegalArgumentException() - parts[this.second as SerializableProperty] = value + this.second.serializer.serializableProperties?.let { + parts[this.second as SerializableProperty] = partialOf(value, it as Array>) + } ?: run { + parts[this.second as SerializableProperty] = value + } } } @@ -45,4 +78,20 @@ inline fun partialOf(builder: PartialBuilder.(DataClassPathSelf partialOf(item: T, properties: Array>) = Partial().apply { + properties.forEach { parts[it] = it.get(item) } +} +fun partialOf(item: T, properties: Iterable>) = Partial().apply { + properties.forEach { parts[it] = it.get(item) } +} +@JvmName("partialOfPaths") +fun partialOf(item: T, paths: Iterable>) = Partial().apply { + paths.forEach { it.setMap(item, this) } +} +@JvmName("partialOfPaths") +fun partialOf(item: T, paths: Array>) = Partial().apply { + paths.forEach { it.setMap(item, this) } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lightningkite/serialization/PartialSerializer.kt b/shared/src/commonMain/kotlin/com/lightningkite/serialization/PartialSerializer.kt index 22effdef2..9655ae499 100644 --- a/shared/src/commonMain/kotlin/com/lightningkite/serialization/PartialSerializer.kt +++ b/shared/src/commonMain/kotlin/com/lightningkite/serialization/PartialSerializer.kt @@ -99,7 +99,7 @@ fun DataClassPathPartial.setMap(key: K, out: Partial) { unwrapped.serializableProperties?.let { props -> current.parts[properties.last() as SerializableProperty] = getAny(key)?.let { @Suppress("UNCHECKED_CAST") - Partial(it, props.map { DataClassPathAccess(DataClassPathSelf(prop.serializer as KSerializer), it as SerializableProperty) }) + partialOf(it, props.map { DataClassPathAccess(DataClassPathSelf(prop.serializer as KSerializer), it as SerializableProperty) }) } } ?: run { current.parts[properties.last() as SerializableProperty] = getAny(key) diff --git a/shared/src/commonMain/kotlin/com/lightningkite/serialization/SerializationRegistry.kt b/shared/src/commonMain/kotlin/com/lightningkite/serialization/SerializationRegistry.kt index 354122826..f5df18034 100644 --- a/shared/src/commonMain/kotlin/com/lightningkite/serialization/SerializationRegistry.kt +++ b/shared/src/commonMain/kotlin/com/lightningkite/serialization/SerializationRegistry.kt @@ -242,7 +242,7 @@ class SerializationRegistry(val module: SerializersModule) { annotations = it.annotations.mapNotNull { SerializableAnnotation.parseOrNull(it) }, defaultJson = it.default?.let { default -> @Suppress("UNCHECKED_CAST") - defjs.encodeToString(it.serializer as KSerializer, default) + DefaultDecoder.json.encodeToString(it.serializer as KSerializer, default) } ) } ?: (value as? GeneratedSerializer<*>)?.let { @@ -321,7 +321,7 @@ class SerializationRegistry(val module: SerializersModule) { annotations = it.annotations.mapNotNull { SerializableAnnotation.parseOrNull(it) }, defaultJson = it.default?.let { default -> @Suppress("UNCHECKED_CAST") - defjs.encodeToString(it.serializer as KSerializer, default) + DefaultDecoder.json.encodeToString(it.serializer as KSerializer, default) } ) } ?: (value as? GeneratedSerializer<*>)?.let { @@ -382,4 +382,3 @@ class SerializationRegistry(val module: SerializersModule) { } } -private val defjs = Json { serializersModule = DefaultDecoder.serializersModule } diff --git a/shared/src/commonMain/kotlin/com/lightningkite/serialization/VirtualType.kt b/shared/src/commonMain/kotlin/com/lightningkite/serialization/VirtualType.kt index ccc0d8f2f..5259ba7d5 100644 --- a/shared/src/commonMain/kotlin/com/lightningkite/serialization/VirtualType.kt +++ b/shared/src/commonMain/kotlin/com/lightningkite/serialization/VirtualType.kt @@ -38,6 +38,7 @@ data class VirtualStruct( override fun toString(): String = "virtual data class $serialName${parameters.takeUnless { it.isEmpty() }?.joinToString(", ", "<", ">") { it.name } ?: ""}(${fields.joinToString()})" + object DefaultNotPresent inner class Concrete(val registry: SerializationRegistry, val arguments: Array>) : KSerializer, VirtualType by this@VirtualStruct { val struct = this@VirtualStruct @@ -55,6 +56,11 @@ data class VirtualStruct( it.type.serializer(registry, context) } } + val specifiedDefaults by lazy { + fields.zip(serializers) { field, serializer -> + field.defaultJson?.let { DefaultDecoder.json.decodeFromString(serializer, it) } ?: DefaultNotPresent + } + } val defaults by lazy { serializers.map { it.default() } } @@ -110,7 +116,7 @@ data class VirtualStruct( val s = encoder.beginStructure(descriptor) for ((index, field) in fields.withIndex()) { val v = value.values[index] - if (v != defaults[index] || s.shouldEncodeElementDefault(descriptor, index)) { + if (v != specifiedDefaults[index] || s.shouldEncodeElementDefault(descriptor, index)) { val ser = serializers[index] s.encodeSerializableElement(descriptor, index, ser, v) } diff --git a/shared/src/commonTest/kotlin/com/lightningkite/UUIDTest.kt b/shared/src/commonTest/kotlin/com/lightningkite/UUIDTest.kt new file mode 100644 index 000000000..8bc651134 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/lightningkite/UUIDTest.kt @@ -0,0 +1,14 @@ +package com.lightningkite + +import kotlin.test.Test +import kotlin.test.assertEquals + +class UUIDTest { + @OptIn(ExperimentalStdlibApi::class) + @Test fun testBytes() { + val uuid = UUID.random() + println(uuid.toBytes().toHexString()) + println(uuid.toString()) + assertEquals(uuid, UUID.parse(uuid.toBytes())) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/lightningkite/lightningdb/SerializationTest.kt b/shared/src/commonTest/kotlin/com/lightningkite/lightningdb/SerializationTest.kt index a035aa90e..22416eef3 100644 --- a/shared/src/commonTest/kotlin/com/lightningkite/lightningdb/SerializationTest.kt +++ b/shared/src/commonTest/kotlin/com/lightningkite/lightningdb/SerializationTest.kt @@ -131,6 +131,16 @@ class SerializationTest { val restored = myJson.decodeFromString(serializer, asText) assertEquals(item, restored) } + @Test fun partialMods() { + val p = partialOf { + it.int assign 3 + it.embedded assign partialOf { + it.value2 assign 4 + } + it.embeddedNullable assign ClassUsedForEmbedding("test") + } + println(p.toModification()) + } @Test fun dataClassPathForgotQuestionMark() { println(DataClassPathSerializer(LargeTestModel.serializer()).fromString("embeddedNullable.value1")) } diff --git a/shared/src/iosMain/kotlin/com/lightningkite/UUID.ios.kt b/shared/src/iosMain/kotlin/com/lightningkite/UUID.ios.kt index 701bc8133..188fb26ed 100644 --- a/shared/src/iosMain/kotlin/com/lightningkite/UUID.ios.kt +++ b/shared/src/iosMain/kotlin/com/lightningkite/UUID.ios.kt @@ -1,5 +1,11 @@ +@file:OptIn(ExperimentalForeignApi::class) + package com.lightningkite +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned import kotlinx.serialization.Serializable import platform.Foundation.NSUUID @@ -12,6 +18,19 @@ actual class UUID(val ns: NSUUID): Comparable { actual companion object { actual fun random(): UUID = UUID(NSUUID()) actual fun parse(uuidString: String): UUID = UUID(NSUUID(uuidString)) + actual fun parse(bytes: ByteArray): UUID { + return bytes.usePinned { + UUID(NSUUID(it.addressOf(0).reinterpret())) + } + } + } + + actual fun toBytes(): ByteArray { + val out = ByteArray(16) + out.usePinned { p -> + ns.getUUIDBytes(p.addressOf(0).reinterpret()) + } + return out } } diff --git a/shared/src/jsMain/kotlin/UUID.js.kt b/shared/src/jsMain/kotlin/UUID.js.kt index 708fe2e65..2c7c94867 100644 --- a/shared/src/jsMain/kotlin/UUID.js.kt +++ b/shared/src/jsMain/kotlin/UUID.js.kt @@ -2,6 +2,7 @@ package com.lightningkite import kotlinx.browser.window import kotlinx.serialization.Serializable +import org.khronos.webgl.Uint8Array @Serializable(DeferToContextualUuidSerializer::class) actual data class UUID(val string: String): Comparable { @@ -14,5 +15,54 @@ actual data class UUID(val string: String): Comparable { private val uuidRegex = Regex("^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\$") actual fun random(): UUID = UUID(window.asDynamic().crypto.randomUUID() as String) actual fun parse(uuidString: String): UUID = UUID(uuidString) + private val charmap = "0123456789abcdef" + actual fun parse(bytes: ByteArray): UUID { + var out = "" + var i = 0 + repeat(4) { + val byte = bytes[i++].toUByte().toInt() + out += charmap[byte.shr(4).and(0xF)] + out += charmap[byte.and(0xF)] + } + out += "-" + repeat(2) { + val byte = bytes[i++].toUByte().toInt() + out += charmap[byte.shr(4).and(0xF)] + out += charmap[byte.and(0xF)] + } + out += "-" + repeat(2) { + val byte = bytes[i++].toUByte().toInt() + out += charmap[byte.shr(4).and(0xF)] + out += charmap[byte.and(0xF)] + } + out += "-" + repeat(2) { + val byte = bytes[i++].toUByte().toInt() + out += charmap[byte.shr(4).and(0xF)] + out += charmap[byte.and(0xF)] + } + out += "-" + repeat(6) { + val byte = bytes[i++].toUByte().toInt() + out += charmap[byte.shr(4).and(0xF)] + out += charmap[byte.and(0xF)] + } + return UUID(out) + } + } + + actual fun toBytes(): ByteArray { + var i = 0 + //ce73b77c-f085-47aa-b255-08b33cf7ca98 + return ByteArray(16) { it -> + val out = string.substring(i, i + 2).toInt(16).toByte() + i += 2 + if(i == 8) i++ + if(i == 13) i++ + if(i == 18) i++ + if(i == 23) i++ + out + } } } \ No newline at end of file diff --git a/shared/src/jvmMain/kotlin/com/lightningkite/UUID.jvm.kt b/shared/src/jvmMain/kotlin/com/lightningkite/UUID.jvm.kt index 3ffee2336..f78abd4a0 100644 --- a/shared/src/jvmMain/kotlin/com/lightningkite/UUID.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/lightningkite/UUID.jvm.kt @@ -15,9 +15,45 @@ actual data class UUID( else this.leastSignificantBits.compareTo(other.leastSignificantBits) } + actual fun toBytes(): ByteArray = byteArrayOf( + mostSignificantBits.shr(8 * 7).toByte(), + mostSignificantBits.shr(8 * 6).toByte(), + mostSignificantBits.shr(8 * 5).toByte(), + mostSignificantBits.shr(8 * 4).toByte(), + mostSignificantBits.shr(8 * 3).toByte(), + mostSignificantBits.shr(8 * 2).toByte(), + mostSignificantBits.shr(8 * 1).toByte(), + mostSignificantBits.shr(8 * 0).toByte(), + leastSignificantBits.shr(8 * 7).toByte(), + leastSignificantBits.shr(8 * 6).toByte(), + leastSignificantBits.shr(8 * 5).toByte(), + leastSignificantBits.shr(8 * 4).toByte(), + leastSignificantBits.shr(8 * 3).toByte(), + leastSignificantBits.shr(8 * 2).toByte(), + leastSignificantBits.shr(8 * 1).toByte(), + leastSignificantBits.shr(8 * 0).toByte(), + ) actual companion object { actual fun random(): UUID = java.util.UUID.randomUUID().let { UUID(it.mostSignificantBits.toULong(), it.leastSignificantBits.toULong()) } actual fun parse(uuidString: String): UUID = java.util.UUID.fromString(uuidString).let { UUID(it.mostSignificantBits.toULong(), it.leastSignificantBits.toULong()) } + actual fun parse(bytes: ByteArray): UUID = UUID( + bytes[0].toUByte().toULong().shl(8*7) or + bytes[1].toUByte().toULong().shl(8*6) or + bytes[2].toUByte().toULong().shl(8*5) or + bytes[3].toUByte().toULong().shl(8*4) or + bytes[4].toUByte().toULong().shl(8*3) or + bytes[5].toUByte().toULong().shl(8*2) or + bytes[6].toUByte().toULong().shl(8*1) or + bytes[7].toUByte().toULong().shl(8*0) , + bytes[8].toUByte().toULong().shl(8*7) or + bytes[9].toUByte().toULong().shl(8*6) or + bytes[10].toUByte().toULong().shl(8*5) or + bytes[11].toUByte().toULong().shl(8*4) or + bytes[12].toUByte().toULong().shl(8*3) or + bytes[13].toUByte().toULong().shl(8*2) or + bytes[14].toUByte().toULong().shl(8*1) or + bytes[15].toUByte().toULong().shl(8*0) , + ) } override fun toString(): String {