I am creating a websocket server and need to defin...
# getting-started
r
I am creating a websocket server and need to define an API spec for the json objects I send to the frontend. I want the JSON to look like this
Copy code
// A:1
{
    "type": "game",
    "event": "NPC spawned"
    "who": "id2134789s3"
    "where": [500, 500]
}
// A:2
{
    "type": "game",
    "event": "NPC death",
    "who": "id2134789s3",
    "where": [500, 500]
}
// A:3
{
    "type": "game",
    "event": "quest completed"
    "title": "A New Day..."
}
// B:1
{
    "type": "connection",
    "event": "player connected"
    "username": "user538"
}
// B:2
{
    "type": "connection",
    "event": "player disconnected",
    "username": "user538"
}
// B:3
{
    "type": "connection",
    "event": "message sent"
    "msgcontent": "hello!",
    "sender": "user538",
    "receiver": "user998"
}
I assume I am supposed to do this using sealed interfaces and nested data classes... or something else? (I do not want to hard code it. I want type safety and intellisense) You can see the are two main categories
"game"
"connection"
, that each have their own sub event categories, and each sub event category is associated with it's own data variables like
"who"
or
"sender"
. So a single flat sealed interface/enum is not going to be adequate. This is pretty trivial in typescript because of their literal string subsets and discriminated unions / mapped interfaces techniques. But seeming tricky in Kotlin
a
Are you creating your server with Kotlin/JVM, or Multiplatform?
Do you want to use kotlinx.serialization for encoding/decoding? (I presume yes, because your messages above are asking about Ktor)
r
@Adam S Ktor JVM I planned on using data classes and then the toString encode function
👍 1
a
To give a bit of context, there's several different serialization libraries for JVM. Jackson is the most popular one in the Java world, but KxS is the official Kotlin serialization lib. I think Ktor supports both - but KxS is probably better.
r
whichever is more ergonomic and elegant write is what i was interested in
a
well... KxS is more ergonomic and elegant. But Jackson is older, so it has more features. It's possible to do everything in KxS, but some things might require some assembly or compromise.
Talking about compromise... in the JSON you shared there's separate
type
and
event
fields. How important is that that they're seperate? It'd be a lot more convenient for KxS to have them as a single field. Are you only expecting to use the JSON from Kotlin?
r
I am using typescript on the frontend, which is smart enough to be able to know that if I write
Copy code
if (data.type == "game")
   data.event // TS infers this cannot possibly be "message sent", only things like "NPC spawn", etc.
if I collapse type and event into the same thing, then I will not have a way to discriminate on the specific data values
i.e "npc spawn" should have a "who" field
but "message sent" should not
well I guess it is not super important that I have the top level category of "game" vs "connection"
i would just have to consciously memorize that "player join" and "NPC death" are conceptually different kinds of events.
a
you can do something similar in Kotlin using sealed classes - but yeah, Kotlin's type system is not as powerful as TypeScript's.
r
i am looking at sealed class approach and it looks kind of weird
AI gave me this
Copy code
sealed interface Message {
  val type: String

  sealed interface GameMessage : Message {
    override val type: String = "game"

    data class NpcSpawned(val who: String, val where: List<Double>) : GameMessage
    data class NpcDeath(val who: String, val where: List<Double>) : GameMessage
    data class QuestCompleted(val title: String) : GameMessage
  }

  sealed interface ConnectionMessage : Message {
    override val type: String = "connection"

    data class PlayerConnected(val username: String) : ConnectionMessage
    data class PlayerDisconnected(val username: String) : ConnectionMessage
    data class MessageSent(val msgcontent: String, val sender: String, val receiver: String) : ConnectionMessage
  }
}
not sure how JSONable this is.
a
actually, I remember I solved a similar problem.... it might help to look through, but I'll write a brief summary https://github.com/adamko-dev/kafkatorio/blob/v0.9.15/modules/events-library/src/commonMain/kotlin/dev/adamko/kafkatorio/schema/packets/kafkatorioPacket.kt
yeah that code looks okay, but KxS will automatically add a 'type' field for sealed polymorphic serialization https://github.com/Kotlin/kotlinx.serialization/blob/v1.6.3/docs/polymorphism.md#sealed-classes
r
man, this is surprisingly difficult to work with simple json in ktolin
a
yeah, it can be at first
KxS can be really hard
r
every solution I think of does not work with arbitrary depth..
only 1 or 2 levels
where a has sub category b with sub categories c with sub categories d
that is super boilerplatey and hard in kotlin
i feel discouraged from organizing my json api
like I am being pressured to have a small flat hierarchy
a
something that is REALLY cool with KxS is that once something is serializable, you can (usually) instantly switch from JSON to other encodings, like an ultra compact binary format. So you can use JSON when debugging, but in a release build you can use binary, so it'll be much faster.
Basically, in KxS you need a distinct
"type"
field. There's no 'shape based' decoding (out of the box, anyway - you can roll your own). And having multiple 'type' fields is possible, but it's kind of a pain to do it manually. What will probably be better is to split up the main event types from the subtypes, so you'll have a nested object:
Copy code
{
    "type": "game",
    "content": {
        "type": "NPC spawned"
        "who": "id2134789s3"
        "where": [500, 500]
    }
}
Something like this!
note that there's no
type
field - that'll be added automatically by KxS
and then in TypeScript you'd do
if (message.type == "game")
, and in Kotlin you'd do
if (message is Message.Game)
r
@Adam S what about the
message.event
a
I renamed it to
message.content.type
type
is the default JSON discriminator in KxS - you can rename it back to
event
if you like - see
@JsonClassDiscriminator
https://github.com/Kotlin/kotlinx.serialization/blob/v1.6.3/docs/json.md#class-discriminator-for-polymorphism
r
I don't know how scalable that is. Like if I had 1 extra level of consistent subcategorizing...
I am looking into how to go the other way now: convert a string into the appropriate data class
and it looks like a lot of steps
I think I am just going to abandon type safety as a goal here. Handle everything as raw strings. If a language lacks a powerful type system and makes very simple mundane stuff like arbitrarily nested json objects tedious, I am just going to bypass it
like, the time that it will cost me to just risk and fix runtime bugs, is much less than the time it is going to cost me to craft out all of the boilerplate required to make it register at the type level. I will get burnt out and my project will die like has happened many times in the past when I start encountering the inadequacies of a language and spend too long trying to make it good.
anyway thank you for your help and suggestions. I might use them at some point
Copy code
val jsonData = Json.encodeToString(MyModel.serializer(), MyModel(42))
where
MyModel
is the specific data class...
that means I am going to have to manually switch case over my entirey hierarchy of data classes... to know how to build the right json object. I might as well just manually switch case over the raw strings.
Do i have to use data classes to use serializable?
a
you don't have to use data classes, you can use anything. You can hand-write serializers, but using
@Serializable
so that the plugin generates a serializer is the easiest way. But
@Serializable
has some requirements, like the constructor args must be properties.
the easiest way to do it without nesting would be to combine your type/event fields, like this:
Copy code
sealed interface GamePackets {
  @Serializable
  @SerialName("connection/message sent")
  data class ConnectionMessageSent(
    val msgContent: String
  ): GamePackets

  // more packets ...
}
That'd create JSON like this
Copy code
{
  "type": "connection/message sent",
  "msgContent": "x"
}
> that means I am going to have to manually switch case over my entirey hierarchy of data classes... to know how to build the right json object. Oh, that shouldn't be necessary. There's an extension function so you can just call
Json.encodeToString(MyModel(42))
, and KxS will figure out the correct serializer. There's an example here.
r
@Adam S Does serialization work for compositions of objects? Say I have a @Serializable data class, who contains a Foo object, and that Foo object contains a Bar object, and so on.
Copy code
class Bar { }
class Foo {
  bar: Bar
}

@Serializable
data class Context(val a: Int, val obj: Foo) {}
Do all three of these classes need to be marked as @Serializable?
or will it recursively serialize everything
a
that's probably not supported out-of-the-box https://github.com/Kotlin/kotlinx.serialization/issues/15#issuecomment-656581859 There might be a way to implement support manually, but there will probably be easier ways of achieving the same result.
j
If a language lacks a powerful type system and makes very simple mundane stuff like arbitrarily nested json objects tedious, I am just going to bypass it
The language allows nested sealed hierarchies exactly like you need. Serializing those as JSON, there is no need to express every hierarchy level in the resulting JSON, that's just a waste. You only ever need one "type" field no matter how deep the hierarchy is. For instance, if your
type
is
NpcDeath
, you don't need any extra information to know that it's a
GameMessage
and a
Message
.
So basically the JSON representation with 2 fields for
type
and
event
is a bit redundant, because if the recipient of the message knows the type hierarcy,
event
should be sufficient to deduce
type
I believe you only need this:
Copy code
sealed interface Message {
  sealed interface GameMessage : Message {
    data class NpcSpawned(val who: String, val where: List<Double>) : GameMessage
    data class NpcDeath(val who: String, val where: List<Double>) : GameMessage
    data class QuestCompleted(val title: String) : GameMessage
  }

  sealed interface ConnectionMessage : Message {
    data class PlayerConnected(val username: String) : ConnectionMessage
    data class PlayerDisconnected(val username: String) : ConnectionMessage
    data class MessageSent(val msgcontent: String, val sender: String, val receiver: String) : ConnectionMessage
  }
}
The
type
field will be handled by Kotlinx Serialization automatically at every level in your hierarchy where a polymorphic type is used
And you'll get this
Copy code
// A:1
{
    "type": "your.package.NpcSpawned",
    "who": "id2134789s3",
    "where": [500, 500]
}
// A:2
{
    "type": "your.package.NpcDeath",
    "who": "id2134789s3",
    "where": [500, 500]
}
// A:3
{
    "type": "your.package.QuestCompleted",
    "title": "A New Day..."
}
// B:1
{
    "type": "your.package.PlayerConnected",
    "username": "user538"
}
// B:2
{
    "type": "your.package.PlayerDisconnected",
    "username": "user538"
}
// B:3
{
    "type": "your.package.MessageSent",
    "msgcontent": "hello!",
    "sender": "user538",
    "receiver": "user998"
}