Skip to content

Commit

Permalink
Merge pull request #256 from LossyDragon/generateUnifiedMethods
Browse files Browse the repository at this point in the history
Automatically generate unified methods
  • Loading branch information
LossyDragon authored Apr 21, 2024
2 parents b1472da + 6716785 commit c388103
Show file tree
Hide file tree
Showing 61 changed files with 580 additions and 2,442 deletions.
1 change: 1 addition & 0 deletions .run/javasteam [Generated Classes].run.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<option value="generateProto" />
<option value="generateSteamLanguage" />
<option value="generateProjectVersion" />
<option value="generateRpcMethods" />
</list>
</option>
<option name="vmOptions" />
Expand Down
9 changes: 5 additions & 4 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ plugins {
`maven-publish`
alias(libs.plugins.kotlin.dokka)
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.lint)
alias(libs.plugins.kotlin.kotlinter)
alias(libs.plugins.maven.publish)
alias(libs.plugins.protobuf.gradle)
id("jacoco")
id("signing")
projectversiongen
steamlanguagegen
rpcinterfacegen
}

allprojects {
Expand Down Expand Up @@ -88,14 +89,14 @@ sourceSets.main {
java.srcDirs(
// builtBy() fixes gradle warning "Execution optimizations have been disabled for task"
files("build/generated/source/steamd/main/java").builtBy("generateSteamLanguage"),
files("build/generated/source/javasteam/main/java").builtBy("generateProjectVersion")
files("build/generated/source/javasteam/main/java").builtBy("generateProjectVersion", "generateRpcMethods")
)
}

/* Dependencies */
tasks["lintKotlinMain"].dependsOn("formatKotlin")
tasks["check"].dependsOn("jacocoTestReport")
tasks["compileJava"].dependsOn("generateSteamLanguage")
tasks["compileJava"].dependsOn("generateProjectVersion")
tasks["compileJava"].dependsOn("generateSteamLanguage", "generateProjectVersion", "generateRpcMethods")
tasks["build"].finalizedBy(dokkaJavadocJar)

dependencies {
Expand Down
7 changes: 6 additions & 1 deletion buildSrc/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
plugins {
`kotlin-dsl`
`java-gradle-plugin`
id("org.jlleitschuh.gradle.ktlint") version "12.1.0" // https://github.com/JLLeitschuh/ktlint-gradle/releases
}

version = "1.0.0"
Expand All @@ -15,6 +14,8 @@ dependencies {

// https://mvnrepository.com/artifact/commons-io/commons-io
implementation("commons-io:commons-io:2.16.0")
// https://mvnrepository.com/artifact/com.squareup/kotlinpoet
implementation("com.squareup:kotlinpoet:1.16.0")
}

gradlePlugin {
Expand All @@ -27,5 +28,9 @@ gradlePlugin {
id = "projectversiongen"
implementationClass = "in.dragonbra.generators.versions.VersionGenPlugin"
}
create("rpcinterfacegen") {
id = "rpcinterfacegen"
implementationClass = "in.dragonbra.generators.rpc.RpcGenPlugin"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package `in`.dragonbra.generators.rpc

import org.gradle.api.Plugin
import org.gradle.api.Project

class RpcGenPlugin : Plugin<Project> {
override fun apply(project: Project) {
project.tasks.register("generateRpcMethods", RpcGenTask::class.java)
}
}
40 changes: 40 additions & 0 deletions buildSrc/src/main/kotlin/in/dragonbra/generators/rpc/RpcGenTask.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package `in`.dragonbra.generators.rpc

import `in`.dragonbra.generators.rpc.parser.ProtoParser
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.TaskAction
import java.io.File

open class RpcGenTask : DefaultTask() {

companion object {
private const val KDOC_AUTHOR = "Lossy"
private const val KDOC_DATE = "2024-04-10"

val kDocClass = """
|@author $KDOC_AUTHOR
|@since $KDOC_DATE
"""
.trimMargin()
}

private val outputDir = File(
project.layout.buildDirectory.get().asFile,
"generated/source/javasteam/main/java/"
)

private val protoDirectory = project.file("src/main/proto")

@TaskAction
fun generate() {
println("Generating RPC service methods as interfaces")

outputDir.mkdirs()

val protoParser = ProtoParser(outputDir)

protoDirectory.walkTopDown()
.filter { it.isFile && it.extension == "proto" }
.forEach(protoParser::parseFile)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package `in`.dragonbra.generators.rpc.parser

import com.squareup.kotlinpoet.*
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import `in`.dragonbra.generators.rpc.RpcGenTask
import java.io.File
import java.util.*

class ProtoParser(private val outputDir: File) {

private companion object {
private const val RPC_PACKAGE = "in.dragonbra.javasteam.rpc"
private const val SERVICE_PACKAGE = "${RPC_PACKAGE}.service"
private const val INTERFACE_PACKAGE = "$RPC_PACKAGE.interfaces"

private val suppressAnnotation = AnnotationSpec
.builder(Suppress::class)
.addMember("%S", "KDocUnresolvedReference") // IntelliJ's seems to get confused with canonical names
.addMember("%S", "RedundantVisibilityModifier") // KotlinPoet is an explicit API generator
.addMember("%S", "unused") // All methods could be used.
.build()

private val classAsyncJobSingle = ClassName(
"in.dragonbra.javasteam.types",
"AsyncJobSingle"
)
private val classServiceMethodResponse = ClassName(
"in.dragonbra.javasteam.steam.handlers.steamunifiedmessages.callback",
"ServiceMethodResponse"
)

private val kDocNoResponse = """|No return value.""".trimMargin()
private fun kDocReturns(requestClassName: ClassName, returnClassName: ClassName): String = """
|@param request The request.
|@see [${requestClassName.simpleName}]
|@returns [${returnClassName.canonicalName}]
""".trimMargin()
}

/**
* Open a .proto file and find all service interfaces.
* Then grab the name of the RPC interface name and everything between the curly braces
* Then loop through all RPC interface methods, destructuring them to name, type, and response and put them in a list.
* Collect the items into a [Service] and pass it off to [buildInterface] and [buildClass]
*/
fun parseFile(file: File) {
Regex("""service\s+(\w+)\s*\{([^}]*)}""")
.findAll(file.readText())
.forEach { serviceMatch ->
val serviceMethods = mutableListOf<ServiceMethod>() // Method list

val serviceName = serviceMatch.groupValues[1]
val methodsContent = serviceMatch.groupValues[2]

Regex("""rpc\s+(\w+)\s*\((.*?)\)\s*returns\s*\((.*?)\);""")
.findAll(methodsContent)
.forEach { methodMatch ->
val (methodName, requestType, responseType) = methodMatch.destructured
val request = requestType.trim().replace(".", "")
val response = responseType.trim().replace(".", "")

ServiceMethod(methodName, request, response).also(serviceMethods::add)
}

Service(serviceName, serviceMethods).also { service ->
println("[${file.name}] - found \"${service.name}\", which has ${service.methods.size} methods")

buildInterface(file, service)
buildClass(file, service)
}
}
}

/**
* Transforms the .proto file into an import statement.
* Also handle edge cases if they are discovered.
*
* Example: steammessages_contentsystem.steamclient.proto to SteammessagesContentsystemSteamclient
*/
private fun transformProtoFileName(protoFileName: String): String {
// Edge cases
if (protoFileName == "steammessages_remoteclient_service.steamclient.proto") {
return "SteammessagesRemoteclientServiceMessages"
}

return protoFileName
.removeSuffix(".proto")
.split("[._]".toRegex())
.joinToString("") { str ->
str.replaceFirstChar { char ->
if (char.isLowerCase()) {
char.titlecase(Locale.getDefault())
} else {
char.toString()
}
}
}
}

/**
* Build the [Service] to an interface with all known RPC methods.
*/
private fun buildInterface(file: File, service: Service) {
// Interface Builder
val iBuilder = TypeSpec
.interfaceBuilder("I${service.name}")
.addAnnotation(suppressAnnotation)
.addKdoc(RpcGenTask.kDocClass)

// Iterate over found 'rpc' methods
val protoFileName = transformProtoFileName(file.name)
service.methods.forEach { method ->
val requestClassName = ClassName(
packageName = "in.dragonbra.javasteam.protobufs.steamclient.$protoFileName",
method.requestType
)

// Make a method
val funBuilder = FunSpec
.builder(method.methodName.replaceFirstChar { it.lowercase(Locale.getDefault()) })
.addModifiers(KModifier.ABSTRACT)
.addParameter("request", requestClassName)

// Add method kDoc
// Add `AsyncJobSingle<ServiceMethodResponse>` if there is a response
if (method.responseType == "NoResponse") {
funBuilder.addKdoc(kDocNoResponse)
} else {
val returnClassName = ClassName(
packageName = "in.dragonbra.javasteam.protobufs.steamclient.$protoFileName",
method.responseType
)
val kDoc = kDocReturns(requestClassName, returnClassName)
funBuilder.addKdoc(kDoc)
.returns(classAsyncJobSingle.parameterizedBy(classServiceMethodResponse))
}

// Add the function to the interface class.
iBuilder.addFunction(funBuilder.build())
}

// Build everything together and write it
FileSpec.builder(INTERFACE_PACKAGE, "I${service.name}")
.addType(iBuilder.build())
.build()
.writeTo(outputDir)
}

/**
* Build the [Service] to a class with all known RPC methods.
*/
private fun buildClass(file: File, service: Service) {
// Class Builder
val cBuilder = TypeSpec
.classBuilder(service.name)
.addAnnotation(suppressAnnotation)
.addKdoc(RpcGenTask.kDocClass)
.primaryConstructor(
FunSpec.constructorBuilder()
.addParameter(
name = "steamUnifiedMessages",
type = ClassName(
packageName = "in.dragonbra.javasteam.steam.handlers.steamunifiedmessages",
"SteamUnifiedMessages"
)
)
.build()
)
.addSuperclassConstructorParameter("steamUnifiedMessages")
.superclass(
ClassName(
packageName = "in.dragonbra.javasteam.steam.handlers.steamunifiedmessages",
"UnifiedService"
)
)
.addSuperinterface(
ClassName(
packageName = "in.dragonbra.javasteam.rpc.interfaces",
"I${service.name}"
)
)

// Iterate over found 'rpc' methods.
val protoFileName = transformProtoFileName(file.name)
service.methods.forEach { method ->
val requestClassName = ClassName(
packageName = "in.dragonbra.javasteam.protobufs.steamclient.$protoFileName",
method.requestType
)

// Make a method
val funBuilder = FunSpec
.builder(method.methodName.replaceFirstChar { it.lowercase(Locale.getDefault()) })
.addModifiers(KModifier.OVERRIDE)
.addParameter("request", requestClassName)

// Add method kDoc
// Add `AsyncJobSingle<ServiceMethodResponse>` if there is a response
if (method.responseType == "NoResponse") {
funBuilder.addKdoc(kDocNoResponse)
.addStatement("sendNotification(request, %S)", method.methodName)
} else {
val returnClassName = ClassName(
packageName = "in.dragonbra.javasteam.protobufs.steamclient.$protoFileName",
method.responseType
)
val kDoc = kDocReturns(requestClassName, returnClassName)
funBuilder.addKdoc(kDoc)
.returns(classAsyncJobSingle.parameterizedBy(classServiceMethodResponse))
.addStatement("return sendMessage(request, %S)", method.methodName)
}

cBuilder.addFunction(funBuilder.build())
}

// Build everything together and write it
FileSpec.builder(SERVICE_PACKAGE, service.name)
.addType(cBuilder.build())
.build()
.writeTo(outputDir)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package `in`.dragonbra.generators.rpc.parser

data class Service(val name: String, val methods: List<ServiceMethod>)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package `in`.dragonbra.generators.rpc.parser

data class ServiceMethod(val methodName: String, val requestType: String, val responseType: String)
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ open class VersionGenTask : DefaultTask() {

private val outputDir = File(
project.layout.buildDirectory.get().asFile,
"generated/source/javasteam/main/java/$PACKAGE"
"generated/source/javasteam/main/java/in/dragonbra/javasteam/util"
)

@TaskAction
Expand Down
4 changes: 2 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
java = "11"
kotlin = "1.9.23" # https://kotlinlang.org/docs/releases.html#release-details
dokka = "1.9.20" # https://mvnrepository.com/artifact/org.jetbrains.dokka/dokka-gradle-plugin
ktlint = "12.1.0" # https://github.com/JLLeitschuh/ktlint-gradle/releases
kotlinter = "4.3.0" # https://plugins.gradle.org/plugin/org.jmailen.kotlinter

# Standard Library versions
bouncyCastle = "1.78" # https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk18on
Expand Down Expand Up @@ -56,7 +56,7 @@ test-mockito-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.r
[plugins]
kotlin-dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-lint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
kotlin-kotlinter = { id = "org.jmailen.kotlinter", version.ref = "kotlinter" }
maven-publish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "publishPlugin" }
protobuf-gradle = { id = "com.google.protobuf", version.ref = "protobuf-gradle" }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ private void onLoggedOn(LoggedOnCallback callback) {

// now let's send the request, this is done by building a class based off the IPlayer interface.
Player playerService = new Player(steamUnifiedMessages);
favoriteBadge = playerService.GetFavoriteBadge(favoriteBadgeRequest.build()).getJobID();
favoriteBadge = playerService.getFavoriteBadge(favoriteBadgeRequest.build()).getJobID();

// second, build our request object, these are autogenerated and can normally be found in the in.dragonbra.javasteam.protobufs.steamclient package
CPlayer_GetGameBadgeLevels_Request.Builder badgeLevelsRequest = CPlayer_GetGameBadgeLevels_Request.newBuilder();
Expand Down
1 change: 0 additions & 1 deletion src/main/java/in/dragonbra/javasteam/rpc/README.md

This file was deleted.

Loading

0 comments on commit c388103

Please sign in to comment.