Skip to content

Commit

Permalink
feat: use KSP to extract schema
Browse files Browse the repository at this point in the history
This will eventually be folded into the plugin so the end user doesn't
have to do anything.
  • Loading branch information
alecthomas committed Sep 5, 2023
1 parent 1b14365 commit b6369b0
Show file tree
Hide file tree
Showing 15 changed files with 364 additions and 29 deletions.
3 changes: 3 additions & 0 deletions examples/echo-kotlin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ plugins {
// Apply the java-library plugin for API and implementation separation.
`java-library`
id("xyz.block.ftl")
id("com.google.devtools.ksp") version "1.9.0-1.0.11"
}

repositories {
Expand All @@ -12,6 +13,8 @@ repositories {

dependencies {
implementation("xyz.block.ftl:ftl-runtime")
implementation("xyz.block.ftl:ftl-schema")
ksp("xyz.block.ftl:ftl-schema")
}

ftl {
Expand Down
1 change: 1 addition & 0 deletions examples/echo-kotlin/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
org.gradle.caching=true
7 changes: 7 additions & 0 deletions examples/echo-kotlin/settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
plugins {}

rootProject.name = "echo"

includeBuild("../../kotlin-runtime/ftl-runtime") {
dependencySubstitution {
substitute(module("xyz.block.ftl:ftl-runtime")).using(project(":"))
}
}

includeBuild("../../kotlin-runtime/ftl-plugin")

includeBuild("../../kotlin-runtime/ftl-schema") {
dependencySubstitution {
substitute(module("xyz.block.ftl:ftl-schema")).using(project(":"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import xyz.block.ftl.Context
import xyz.block.ftl.Ignore
import xyz.block.ftl.Verb
import xyz.block.ftl.logging.Logging
import xyz.block.ftl.v1.schema.Module
import java.util.concurrent.ConcurrentHashMap
import kotlin.reflect.KClass
import kotlin.reflect.KFunction
Expand Down Expand Up @@ -99,6 +100,10 @@ class Registry(val jvmModuleName: String = defaultJvmModuleName) {
val verb = verbs[verbRef] ?: throw IllegalArgumentException("Unknown verb: $verbRef")
return verb.invokeVerbInternal(context, request)
}

fun schema(): Module {
error("not implemented")
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@ package xyz.block.ftl.registry

import xyz.block.ftl.Context
import xyz.block.ftl.Ingress
import xyz.block.ftl.v1.schema.DataRef
import xyz.block.ftl.v1.schema.Metadata
import xyz.block.ftl.v1.schema.MetadataIngress
import xyz.block.ftl.v1.schema.Verb
import xyz.block.ftl.v1.schema.*
import xyz.block.ftl.v1.schema.Array
import java.time.OffsetDateTime
import kotlin.Boolean
import kotlin.Int
import kotlin.Long
import kotlin.String
import kotlin.collections.Map
import kotlin.reflect.KClass
import kotlin.reflect.KFunction
import kotlin.reflect.KTypeParameter
import kotlin.reflect.full.createType
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.hasAnnotation

internal fun reflectSchemaFromFunc(func: KFunction<*>): Verb? {
if (!func.hasAnnotation<xyz.block.ftl.Verb>()) return null
internal fun reflectSchemaFromFunc(func: KFunction<*>): Module {
if (!func.hasAnnotation<xyz.block.ftl.Verb>()) error("Function must be annotated with @Verb")
if (func.parameters.size != 3) error("Verbs must have exactly two arguments")
if (func.parameters[1].type.classifier != Context::class) error("First argument of verb must be Context")
val requestType =
Expand All @@ -21,14 +27,87 @@ internal fun reflectSchemaFromFunc(func: KFunction<*>): Verb? {
val returnType = func.returnType.classifier ?: error("Return type of verb must be a data class")
if (!(returnType as KClass<*>).isData) error("Return type of verb must be a data class not $returnType")

return Verb(
name = func.name,
request = DataRef(name = requestType.simpleName!!),
response = DataRef(name = returnType.simpleName!!),
metadata = buildList {
func.findAnnotation<Ingress>()?.let {
add(Metadata(ingress = MetadataIngress(method = it.method.toString(), path = it.path)))
}
}
val requestData = reflectSchemaFromDataClass(requestType).map { Decl(data_ = it) }
val responseData = reflectSchemaFromDataClass(returnType).map { Decl(data_ = it) }

return Module(
name = "",
decls = listOf(
Decl(verb = Verb(
name = func.name,
request = DataRef(name = requestType.simpleName!!),
response = DataRef(name = returnType.simpleName!!),
metadata = buildList {
func.findAnnotation<Ingress>()?.let {
add(Metadata(ingress = MetadataIngress(method = it.method.toString(), path = it.path)))
}
}
))
) + requestData + responseData
)
}
}

internal fun reflectSchemaFromDataClass(dataClass: KClass<*>): List<Data> {
if (!dataClass.isData) error("Must be a data class")
return listOf(
Data(
name = dataClass.simpleName!!,
fields = dataClass.constructors.first().parameters
.filter { param -> param.name!! != "_empty" && param.type.classifier != Unit::class }
.map { param ->
Field(
name = param.name!!,
type = reflectType(param.type.classifier as KClass<*>),
)
}
))
}

internal fun reflectType(cls: KClass<*>): Type {
return when (cls) {
String::class -> Type(string = xyz.block.ftl.v1.schema.String())
Int::class -> Type(int = xyz.block.ftl.v1.schema.Int())
Long::class -> Type(int = xyz.block.ftl.v1.schema.Int())
Boolean::class -> Type(bool = Bool())
OffsetDateTime::class -> Type(time = Time())
Map::class -> Type(
map = xyz.block.ftl.v1.schema.Map(
key = reflectTypeParameter(cls.typeParameters[0]),
value_ = reflectTypeParameter(cls.typeParameters[1])
)
)

List::class -> Type(
array = Array(
element = reflectTypeParameter(cls.typeParameters[0])
)
)

else -> Type(dataRef = DataRef(name = cls.simpleName!!))
}
}

internal fun reflectTypeParameter(param: KTypeParameter): Type {
println("reflectTypeParameter: ${param.variance.name}")
return when (param.createType()) {
String::class -> Type(string = xyz.block.ftl.v1.schema.String())
Int::class -> Type(int = xyz.block.ftl.v1.schema.Int())
Long::class -> Type(int = xyz.block.ftl.v1.schema.Int())
Boolean::class -> Type(bool = Bool())
OffsetDateTime::class -> Type(time = Time())
Map::class -> Type(
map = xyz.block.ftl.v1.schema.Map(
key = reflectType(param.upperBounds[0].classifier as KClass<*>),
value_ = reflectType(param.upperBounds[1].classifier as KClass<*>)
)
)

List::class -> Type(
array = Array(
element = reflectType(param.upperBounds[0].classifier as KClass<*>)
)
)

else -> Type(dataRef = DataRef(name = param.name))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package xyz.block.ftl.registry
import xyz.block.ftl.Context
import xyz.block.ftl.logging.Logging
import xyz.block.ftl.serializer.makeGson
import xyz.block.ftl.v1.schema.Module
import xyz.block.ftl.v1.schema.Verb
import kotlin.reflect.KClass
import kotlin.reflect.KFunction
import kotlin.reflect.KParameter
Expand Down Expand Up @@ -36,6 +38,15 @@ internal class VerbHandle<Resp>(
return gson.toJson(result)
}

/**
* Returns the schema for this verb as a [Module].
*
* The Module will contain the Verb, request and response types as top-level declarations.
*/
fun schema(): Module {
return reflectSchemaFromFunc(verbFunction)
}

private fun findArgumentType(parameters: List<KParameter>): KClass<*> {
return parameters.find { param ->
// skip the owning type itself
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package xyz.block.ftl.schemaextractor

import xyz.block.ftl.registry.Registry

fun main() {
val registry = Registry()
registry.registerAll()
println(registry.schema())
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,68 @@ package xyz.block.ftl.registry

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import xyz.block.ftl.v1.schema.DataRef
import xyz.block.ftl.v1.schema.Metadata
import xyz.block.ftl.v1.schema.MetadataIngress
import xyz.block.ftl.v1.schema.Verb
import xyz.block.ftl.Context
import xyz.block.ftl.Ingress
import xyz.block.ftl.Method
import xyz.block.ftl.v1.schema.*
import xyz.block.ftl.v1.schema.String as SchemaString

data class Value(val value: String)

data class SchemaExampleVerbRequest(
val string: String,
val int: Int,
val long: Long,
val map: Map<String, Value>
)

data class SchemaExampleVerbResponse(
val _empty: Unit = Unit,
)

class SchemaExampleVerb {
@xyz.block.ftl.Verb
@Ingress(Method.GET, "/test")
fun verb(context: Context, req: SchemaExampleVerbRequest): SchemaExampleVerbResponse {
return SchemaExampleVerbResponse()
}
}

class SchemaReflectorKtTest {
@Test
fun reflectSchemaFromFunc() {
val expected = Verb(
name = "verb",
request = DataRef(name = "VerbRequest"),
response = DataRef(name = "VerbResponse"),
metadata = listOf(
Metadata(ingress = MetadataIngress(method = "GET", path = "/test")),
),
val expected = Module(
decls = listOf(
Decl(
verb = Verb(
name = "verb",
request = DataRef(name = "VerbRequest"),
response = DataRef(name = "VerbResponse"),
metadata = listOf(
Metadata(ingress = MetadataIngress(method = "GET", path = "/test")),
),
)
),
Decl(
data_ = Data(
name = "VerbRequest",
fields = listOf(
Field(name = "text", type = Type(string = SchemaString())),
)
)
),
Decl(
data_ = Data(
name = "VerbResponse",
fields = listOf(
Field(name = "text", type = Type(string = SchemaString())),
)
)
),
)
)
val actual = reflectSchemaFromFunc(ExampleVerb::verb)
val actual = reflectSchemaFromFunc(SchemaExampleVerb::verb)
println(actual)
assertEquals(expected, actual)
}
}
}
42 changes: 42 additions & 0 deletions kotlin-runtime/ftl-schema/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/

### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/

### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/

### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/

### VS Code ###
.vscode/

### Mac OS ###
.DS_Store
1 change: 1 addition & 0 deletions kotlin-runtime/ftl-schema/bin
41 changes: 41 additions & 0 deletions kotlin-runtime/ftl-schema/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
plugins {
kotlin("jvm") version "1.9.0"
id("com.squareup.wire") version "4.7.2"
`java-library`
}

group = "xyz.block"
version = "1.0-SNAPSHOT"

buildscript {
dependencies {
classpath(kotlin("gradle-plugin", version = "1.9.0"))
}
}

tasks.findByName("wrapper")?.enabled = false

repositories {
mavenCentral()
}

dependencies {
implementation("com.google.devtools.ksp:symbol-processing-api:1.9.0-1.0.11")
implementation(libs.wireGrpcClient)
implementation("xyz.block.ftl:ftl-runtime")
testImplementation(kotlin("test"))
}

tasks.test {
useJUnitPlatform()
}

wire {
kotlin {
rpcRole = "client"
rpcCallStyle = "blocking"
}
sourcePath {
srcDir("../../protos")
}
}
2 changes: 2 additions & 0 deletions kotlin-runtime/ftl-schema/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
kotlin.code.style=official
org.gradle.caching=true
Loading

0 comments on commit b6369b0

Please sign in to comment.