Plugin definition APIs are generally marked with @KrosstalkPluginApi
and defined in *.plugin
packages. The
KrosstalkPluginApi
annotation should not be propagated to users of your plugin. Serialization, client, and server
plugins should depend on the core, client, and server artifacts, respectively, and expose those dependencies (i.e.
use api()
).
If you write a plugin, please strongly consider contributing it, either via a PR or in your own repo. Even if it's not polished, it's very helpful for others writing their own using the same backing implementation. I'm happy to help with plugin API issues, or polishing up contributed plugins in a PR. If you want to keep the plugin in your own repo, let me know, and I'll add a link to it to the README.
For an example, see krosstalk-kotlinx-serialization. This guide will follow its implementation.
A serialization plugin needs to define a SerializationHandler
, which is usable in Krosstalk
objects. To define a
serialization handler, you will first have to define a serializer implementation, implementing
Serializer<T, S>
(
see Serializers.kt)
. A serializer will serialize objects of type T
to format S
, and deserialize from S
to T
.
S
is almost always ByteArray
or String
, and as such typealiases StringSerializer<T>
and
BinarySerializer<T>
are provided.
For Kotlinx serialization, this looks like (source):
@OptIn(KrosstalkPluginApi::class)
public data class KotlinxBinarySerializer<T>(val serializer: KSerializer<T>, val format: BinaryFormat) :
BinarySerializer<T> {
override fun deserialize(data: ByteArray): T = format.decodeFromByteArray(serializer, data)
override fun serialize(data: T): ByteArray = format.encodeToByteArray(serializer, data)
}
@OptIn(KrosstalkPluginApi::class)
public data class KotlinxStringSerializer<T>(val serializer: KSerializer<T>, val format: StringFormat) :
StringSerializer<T> {
override fun deserialize(data: String): T = format.decodeFromString(serializer, data)
override fun serialize(data: T): String = format.encodeToString(serializer, data)
}
Note the use of @OptIn(KrosstalkPluginApi::class)
: we don't want to propagate KrosstalkPluginApi
to users of our
plugin.
Next, you need to define the SerializationHandler<S>
, where S
is again the format (see
Serializers.kt)
. You need to define getSerializer
to get a serializer (presumably of the type you defined) for a KType
, methods to
serialize and deserialize sets of arguments given the arguments and their serializers, and
a transformer: SerializedFormatTransformer<S>
, which can transform your serialized format (S
) to a String
and
ByteArray
. contentType
can optionally be overridden to set the content type used by requests and responses.
Transformers for String
and ByteArray
are already defined (StringTransformer
and ByteTransformer
, respectively),
and you can define custom ones (see
SerializationFormats.kt)
.
Some helper case classes are provided for defining serialization handlers: BaseSerializationHandler
and
ArgumentSerializationHandler
. BaseSerializationHandler
only defines transformer
as a constructor property,
but ArgumentSerializationHandler
goes a bit further and handles argument map serialization for you, only requiring you
to implement Map<String, S>
serialization and deserialization methods (note that S
is your serialized data
format).
Examples of using ArgumentSerializationHandler
can be seen in KotlinxStringSerializationHandler
and
KotlinxBinarySerializationHandler
:
public data class KotlinxBinarySerializationHandler(val format: BinaryFormat) :
ArgumentSerializationHandler<ByteArray>(ByteTransformer) {
override fun serializeArguments(serializedArguments: Map<String, ByteArray>): ByteArray {
return format.encodeToByteArray(mapSerializer, serializedArguments)
}
override fun deserializeArguments(arguments: ByteArray): Map<String, ByteArray> {
return format.decodeFromByteArray(mapSerializer, arguments)
}
override fun getSerializer(type: KType): KotlinxBinarySerializer<*> =
KotlinxBinarySerializer(serializer(type), format)
private val mapSerializer = serializer<Map<String, ByteArray>>()
override val contentType: String = byteArrayContentType
}
public data class KotlinxStringSerializationHandler(val format: StringFormat) :
ArgumentSerializationHandler<String>(StringTransformer) {
override fun serializeArguments(serializedArguments: Map<String, String>): String {
return format.encodeToString(mapSerializer, serializedArguments)
}
override fun deserializeArguments(arguments: String): Map<String, String> {
return format.decodeFromString(mapSerializer, arguments)
}
override fun getSerializer(type: KType): KotlinxStringSerializer<*> =
KotlinxStringSerializer(serializer(type), format)
private val mapSerializer = serializer<Map<String, String>>()
override val contentType: String = stringContentType
}
However, ArgumentSerializationHandler
is not always suitable. Because it serializes all arguments first, you end up
with the arguments being wrapped in strings or byte arrays.In situations where you are using a non-Krosstalk client or
server, this will usually cause issues, so you can extend BaseSerializationHandler
instead
of ArgumentSerializationHandler
and define the combination step yourself. An example of this are the kotlinx serializers:
(source):
public data class KotlinxBinarySerializationHandler(val format: BinaryFormat, override val contentType: String = byteArrayContentType) :
BaseSerializationHandler<ByteArray>(ByteTransformer) {
override fun getSerializer(type: KType): KotlinxBinarySerializer<*> =
KotlinxBinarySerializer(serializer(type), format)
override fun serializeArguments(arguments: Map<String, *>, serializers: ArgumentSerializers<ByteArray>): ByteArray {
val kotlinxSerializers = serializers.map.mapValues { (it.value as KotlinxSerializer).serializer }
val topLevelSerializer = ArgumentSerializer(kotlinxSerializers as Map<String, KSerializer<Any?>>)
return format.encodeToByteArray(topLevelSerializer, arguments)
}
override fun deserializeArguments(
arguments: ByteArray,
serializers: ArgumentSerializers<ByteArray>
): Map<String, *> {
val kotlinxSerializers = serializers.map.mapValues { (it.value as KotlinxSerializer).serializer }
val topLevelSerializer = ArgumentSerializer(kotlinxSerializers as Map<String, KSerializer<Any?>>)
return format.decodeFromByteArray(topLevelSerializer, arguments)
}
}
They avoid the issue by using a custom heterogeneous map serializer (in the same file).
Note the use
of ArgumentSerializers
, which is essentially a {Argument -> Serializer}
with helper functions to get serializers and serialize maps.
For an example, see ktor-client. The main plugin is krosstalk-ktor-client, but artifacts are split out to match Ktor's artifact structure. This guide will follow its implementation.
To define a client, you will need to define a scope for your plugin that extends ClientScope
(and generally preserves
the type parameter) and a ClientHandler<S>
where S
is your scope. You will also generally want to define an
interface implementing KrosstalkClient
that uses your scope and client handler classes. Examples from the Ktor client
plugin
(definitions only):
public class KtorClient : ClientHandler<KtorClientScope<*>>
public interface KtorClientScope<in D> : ClientScope<D>
public interface KtorKrosstalkClient : KrosstalkClient<KtorClientScope<*>> {
override val client: KtorClient
}
The client handler must define a single method:
suspend fun sendKrosstalkRequest(url: String, httpMethod: String, contentType: String, additionalHeaders: Headers, body: ByteArray?, scopes: List<AppliedClientScope<C, *>>,): InternalKrosstalkResponse
(C
is the scope type of the ClientHandler
), which sends the actual HTTP request and returns a
InternalKrosstalkResponse
, which encapsulates the response code, headers, body, and how to get the body as a string. The parameters are mostly
self explanatory: url
is the url to send the request to, httpMethod
is the HTTP method to use, contentType
is the
content type of the request, additionalHeaders
are additional headers to add to the request, and body
is the request
body (or null
if it is empty). scopes
is a list of scopes applied to the method. Remember, from the Scopes section
of the readme, that client scopes take some data and apply it to the request (thus the in
type parameter).
AppliedScope
is a helper class that contains the applied scope
and the data
it was applied with. Each of these
scopes ie enforced (at compile time) to be of the client handler's defined scope type. How exactly a scope is applied to
a request depends on the client implementation, but your scope interface should define the appropriate methods to be
overridden by scopes and called by the client handler.
For the Ktor client, the scope class KtorClientScope
looks like
(source):
public interface KtorClientScope<in D> : ClientScope<D> {
public fun HttpClientConfig<*>.configureClient(data: D) {}
public fun HttpRequestBuilder.configureRequest(data: D) {}
}
These methods are used from helper functions (source):
internal fun <D> AppliedClientScope<KtorClientScope<D>, *>.configureClient(client: HttpClientConfig<*>) {
client.apply {
scope.apply { configureClient(data as D) }
}
}
internal fun <D> AppliedClientScope<KtorClientScope<D>, *>.configureRequest(request: HttpRequestBuilder) {
request.apply {
scope.apply { configureRequest(data as D) }
}
}
The client handler itself then looks like (source):
public class KtorClient(
public val baseClient: HttpClient = HttpClient(),
public val baseRequest: HttpRequestBuilder.() -> Unit = {},
) : ClientHandler<KtorClientScope<*>> {
private val realBaseClient by lazy {
baseClient.config {
expectSuccess = false
}
}
override suspend fun sendKrosstalkRequest(
url: String,
httpMethod: String,
contentType: String,
additionalHeaders: com.rnett.krosstalk.Headers,
body: ByteArray?,
scopes: List<AppliedClientScope<KtorClientScope<*>, *>>,
): InternalKrosstalkResponse {
// configure the client and make the request
val response = realBaseClient.config {
scopes.forEach {
it.configureClient(this)
}
}.use { client ->
client.request<HttpResponse>(urlString = url) {
if (body != null)
this.body = body
this.method = HttpMethod(httpMethod.toUpperCase())
// base request configuration
baseRequest()
// configure scopes
scopes.forEach {
it.configureRequest(this)
}
// add any set headers
additionalHeaders.forEach { (key, list) ->
this.headers.appendAll(key, list)
}
}
}
val bytes = response.receive<ByteArray>()
val charset = response.charset() ?: Charsets.UTF_8
return InternalKrosstalkResponse(response.status.value, response.headers.toMap(), bytes) {
String(
bytes,
charset = charset
)
}
}
}
For an example, see ktor-server. The main plugin is krosstalk-ktor-server, but artifacts are split out to match Ktor's artifact structure. This guide will follow its implementation.
Similarly to clients, to define a server you will need to define a scope that extends ServerScope
(note the out
variance of the type parameter in contract to ClientScope
's in
) and a ServerHandler<S>
where S
is your scope
class. You will also generally want to define an interface implementing KrosstalkServer
that uses your scope and
server handler classes. Examples from the Ktor server plugin (definitions only):
public object KtorServer : ServerHandler<KtorServerScope<*>>
public interface KtorServerScope<S : Any> : ServerScope<S>
public interface KtorKrosstalkServer : KrosstalkServer<KtorServerScope<*>> {
override val server: KtorServer
}
Note that the server handler is an object, and ServerHandler
doesn't define any methods. This is because servers
generally have their own entrypoints, and how you add endpoints may vary greatly. So we instead provide helper methods
to add a Krosstalk's methods to an already existing server. For Ktor, the definition looks like:
public fun <K> K.defineKtor(application: Application) where K : Krosstalk, K : KrosstalkServer<KtorServerScope<*>> {
KtorServer.define(application.routing { }, this)
}
(it will eventually be migrated to multiple receivers)
Server plugins should provide documentation on how to register a Krosstalk object with their server implementation.
Similarly to clients, the server scope interface should define methods that scopes can override to modify request handlers and extract the scope's data. For Ktor, this looks like (source):
public interface KtorServerScope<S : Any> : ServerScope<S> {
public fun Application.configureApplication() {}
public fun Route.wrapEndpoint(optional: Boolean, endpoint: Route.() -> Unit) {}
public fun getData(call: ApplicationCall): S?
}
These methods then must be used by our server handler's registration method. Since there is no method to override, the
registration method will generally get it's information from a Krosstalk
object directly, using Krosstalk. methods
and Krosstalk.serverScopes
. KrosstalkServer.scopesAsType
is also helpful to convert a method's list of scopes to
scopes of the plugin's type (this is enforced at compile time). A
method's Endpoint
(docs)
can be used for resolution. It has its own custom URL resolver that extracts parameters, called using Endpoint.resolve
. You can also get a resolution tree (Endpoint.resolveTree
) or a list of all possible
resolutions (Endpoint. allResolvePaths
) but neither of those have resolve methods (yet) so using Endpoint.resolve
is
recommended.
However, most of the Krosstalk call handling is server independent, so we provide the KrosstalkServer.handle
method to handle the Krosstalk-specific logic
(docs):
public typealias Responder = suspend (statusCode: Int, contentType: String?, responseHeaders: Headers, responseBody: ByteArray) -> Unit
public suspend fun <K> K.handle(
serverUrl: String,
method: MethodDefinition<*>,
requestHeaders: Headers,
urlArguments: Map<String, String>,
requestBody: ByteArray,
scopes: WantedScopes,
handleException: (Throwable) -> Unit = { throw it },
responder: Responder,
): Unit where K : Krosstalk, K : KrosstalkServer<*>
This should be called by every server handler. The Responder
passed should send a response from the server using its
parameters: the status code, content type (or null to not set), additional response headers, and the body.
To define Ktor's registration method, we first need a helper to wrap endpoints in scopes (source):
internal fun wrapScopesHelper(
route: Route,
optional: Boolean,
remaining: MutableList<KtorServerScope<*>>,
final: Route.() -> Unit,
) {
if (remaining.isEmpty())
route.final()
else {
val scope = remaining.removeLast()
scope.apply {
route.wrapEndpoint(optional) {
wrapScopesHelper(this, optional, remaining, final)
}
}
}
}
internal fun wrapScopes(route: Route, optional: Boolean, remaining: List<KtorServerScope<*>>, final: Route.() -> Unit) =
wrapScopesHelper(route, optional, remaining.toMutableList().asReversed(), final)
We also need a custom route selector using Krosstalk's Endpoint resolution as well as some attributes to store the resolved URL arguments and the base url (source):
private val KrosstalkMethodAttribute = AttributeKey<Map<String, String>>("KrosstalkMethodData")
private val KrosstalkMethodBaseUrlAttribute = AttributeKey<String>("KrosstalkMethodBaseUrl")
internal class KrosstalkRouteSelector(val method: MethodDefinition<*>) : RouteSelector(2.0) {
override fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation {
with(context) {
if (call.request.httpMethod.value.toLowerCase() != method.httpMethod.toLowerCase()) {
return RouteSelectorEvaluation.Failed
}
val prefix = segments.take(segmentIndex)
val localUrl = UrlRequest(call.request.uri).withoutPrefixParts(prefix)
val baseUrl =
URLBuilder.createFromCall(call).buildString().substringBefore(localUrl.urlParts.joinToString("/"))
val data = method.endpoint.resolve(localUrl) ?: return RouteSelectorEvaluation.Failed
call.attributes.put(KrosstalkMethodAttribute, data)
call.attributes.put(KrosstalkMethodBaseUrlAttribute, baseUrl)
return RouteSelectorEvaluation(true, 2.0, segmentIncrement = segments.size - segmentIndex)
}
}
override fun toString(): String {
return "(Krosstalk method: ${method.name})"
}
}
We can then define the registration method (note that this is defined in the KtorServer
object for convenience)
(source):
public fun <K> define(base: Route, krosstalk: K) where K : Krosstalk, K : KrosstalkServer<KtorServerScope<*>> {
// apply Application configuration for each defined scopes
base.application.apply {
krosstalk.serverScopes
.forEach {
it.apply {
configureApplication()
}
}
}
base.apply {
// add each method
krosstalk.methods.values.forEach { method ->
this.createChild(KrosstalkRouteSelector(method)).apply {
// wrap the endpoint in the needed scopes
wrapScopes(
this,
false,
method.requiredScopes.let(krosstalk::scopesAsType)
) {
wrapScopes(
this,
true,
method.optionalScopes.let(krosstalk::scopesAsType)
) {
handle {
val data = call.attributes[KrosstalkMethodAttribute]
val body = call.receiveChannel().toByteArray()
val scopes = MutableWantedScopes()
method.allScopes.let(krosstalk::scopesAsType).forEach { scope ->
scope.getData(call)?.let { scopes[scope as KtorServerScope<Any>] = it }
}
krosstalk.handle(call.attributes[KrosstalkMethodBaseUrlAttribute],
method,
call.request.headers.toMap(),
data,
body,
scopes.toImmutable(),
{
application.log.error(
"Server exception during ${method.name}, passed on to client",
it
)
}) { status: Int, contentType: String?, headers: Headers, bytes: ByteArray ->
headers.forEach { (k, v) ->
v.forEach {
call.response.headers.append(k, it, false)
}
}
call.respondBytes(
bytes,
contentType?.let {
try {
ContentType.parse(it)
} catch (t: BadContentTypeFormatException) {
null
}
},
HttpStatusCode.fromValue(status)
)
this.finish()
}
}
}
}
}
}
}
}
Note the use
of MutableWantedScopes
(docs)
to accumulate scope data. This is a utility class provided for just that purpose.