diff --git a/ReadMe.md b/ReadMe.md new file mode 100644 index 00000000..06924ead --- /dev/null +++ b/ReadMe.md @@ -0,0 +1,44 @@ +# Sisyphus + +Sisyphus is the way how we provide backend services. It integrates all tools and libraries needed for designing API which follows the [Google API Improvement Proposals](https://aip.bybutter.com). + +## We are rolling a huge boulder + +Due to we can analyzing product documents completely, it is not particularly difficult to write an exquisite and easy-to-use API at the beginning for most APIs. + +But many people will break the initial design of the API in the endless update of products. + +It's hard to create a strong and extensible API in the whole project lifetime, just like roll a huge boulder endlessly up a steep hill. + +So we need an all-encompassing guide book to guide us in creating, updating, and modifying APIs. + +The [Google API Improvement Proposals](https://aip.bybutter.com) is the all-encompassing guide book. Google created it in their rich and extensive API design experience. Laid the foundation for possession for anyone to create an extensible API. + +## Good tools can help you + +Choosing good tools can help you 'rolling a huge boulder' faster and easier. Sisyphus provides and integrates many tools in your 'boulder rolling' route. + +[**Kotlin**](https://kotlinlang.org/) is our target language, the mature JVM community and concise grammar are the reasons. + +[**Spring boot**](https://spring.io/projects/spring-boot) is our old friend to manage and organize our components. + +[**gRPC**](https://grpc.io/) is our target API framework, and Sisyphus also provides the [HTTP and gRPC Transcoding](https://aip.bybutter.com/127) component for the environment which not compatible with gRPC. + +[**Sisyphus Protobuf**](/lib/sisyphus-protobuf) is our customized protobuf runtime, it design for Kotlin. + +[**Sisyphus gRPC**](/lib/sisyphus-grpc) is our customized gRPC runtime, it design for Kotlin coroutine. + +[**Sisyphus DTO**](/lib/sisyphus-dto) is our way to create structs without protobuf. + +[**Sisyphus Middleware**](/middleware) is our way to connect Sisyphus and other system. + +[**Sisyphus Configuration Artifact**](/middleware/sisyphus-configuration-artifact) is our way to manage configurations and developing environment. + +[**Sisyphus Protobuf Compiler**](/tools/sisyphus-protoc) is our way to generate Kotlin codes by `.proto` files. + +[**Sisyphus Project Plugin**](/tools/sisyphus-project-gradle-plugin) is our way to manage project and configurating Gradle. + +[**Sisyphus Protobuf Plugin**](/tools/sisyphus-protobuf-gradle-plugin) is our way to generate code by `.proto` files in Gradle. + +**And More** tools like [CEL(Common Expression Language)](https://github.com/google/cel-spec), [Filtering](https://aip.bybutter.com/160) and [Ordering](https://aip.bybutter.com/132#ordering) Script will help you to design APIs follow Google AIP. + diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts new file mode 100644 index 00000000..d567d204 --- /dev/null +++ b/bom/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + `java-platform` + id("nebula.maven-publish") + id("sisyphus.project") +} + +group = "com.bybutter.sisyphus" + +dependencies { + constraints { + rootProject.subprojects { + this.plugins.withId("org.gradle.java-base") { + api(this@subprojects) + } + } + } +} \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 00000000..123b288e --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + `java-library` + `kotlin-dsl` + id("idea") + id("sisyphus.project") version "higan-SNAPSHOT" +} + +dependencies { + implementation(platform("com.bybutter.sisyphus:sisyphus-bom:higan-SNAPSHOT")) + implementation("com.bybutter.sisyphus.tools:sisyphus-protobuf-gradle-plugin") + implementation("com.bybutter.sisyphus.tools:sisyphus-project-gradle-plugin") + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.70") + implementation("org.jetbrains.kotlin:kotlin-allopen:1.3.70") + implementation("org.springframework.boot:spring-boot-gradle-plugin:2.2.7.RELEASE") + implementation("org.jlleitschuh.gradle:ktlint-gradle:9.2.1") + implementation("com.github.ben-manes:gradle-versions-plugin:0.27.0") + implementation("io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.0.0-RC15") + implementation("com.netflix.nebula:nebula-publishing-plugin:17.2.1") + implementation("org.gradle.kotlin:plugins:1.2.11") +} \ No newline at end of file diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 00000000..588dcaf8 --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,6 @@ +pluginManagement { + repositories { + mavenLocal() + gradlePluginPortal() + } +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/dependencies.kt b/buildSrc/src/main/kotlin/dependencies.kt new file mode 100644 index 00000000..725d43df --- /dev/null +++ b/buildSrc/src/main/kotlin/dependencies.kt @@ -0,0 +1,154 @@ +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +object Dependencies { + object Kotlin { + private const val group = "org.jetbrains.kotlin" + const val stdlib = "$group:kotlin-stdlib-jdk8" + const val reflect = "$group:kotlin-reflect" + const val poet = "com.squareup:kotlinpoet" + const val plugin = "$group:kotlin-gradle-plugin" + + object Coroutines { + private const val group = "org.jetbrains.kotlinx" + const val core = "$group:kotlinx-coroutines-core" + const val reactor = "$group:kotlinx-coroutines-reactor" + const val guava = "$group:kotlinx-coroutines-guava" + const val jdk = "$group:kotlinx-coroutines-jdk8" + } + } + + object Jackson { + private const val group = "com.fasterxml.jackson.core" + const val databind = "$group:jackson-databind" + const val core = "$group:jackson-core" + const val annotations = "$group:jackson-annotations" + + object Module { + private const val group = "com.fasterxml.jackson.module" + const val kotlin = "$group:jackson-module-kotlin" + } + + object Dataformat { + private const val group = "com.fasterxml.jackson.dataformat" + const val xml = "$group:jackson-dataformat-xml" + const val yaml = "$group:jackson-dataformat-yaml" + const val properties = "$group:jackson-dataformat-properties" + const val cbor = "$group:jackson-dataformat-cbor" + const val smile = "$group:jackson-dataformat-smile" + } + } + + object Spring { + object Framework { + private const val group = "org.springframework" + + const val webflux = "$group:spring-webflux" + + const val tx = "$group:spring-tx" + } + + object Boot { + private const val group = "org.springframework.boot" + + const val boot = "$group:spring-boot-starter" + + const val webflux = "$group:spring-boot-starter-webflux" + + const val jooq = "$group:spring-boot-starter-jooq" + + const val jdbc = "$group:spring-boot-starter-jdbc" + + const val test = "$group:spring-boot-starter-test" + + const val redis = "$group:spring-boot-starter-data-redis" + + const val amqp = "$group:spring-boot-starter-amqp" + + const val cp = "$group:spring-boot-configuration-processor" + + const val jackson = "$group:spring-boot-starter-json" + } + } + + object Proto { + private const val group = "com.google.protobuf" + + const val base = "$group:protobuf-java" + const val apiCompiler = "com.google.api:api-compiler:0.0.8" + + const val runtimeProto = "$group:protobuf-java:3.11.4" + const val grpcProto = "com.google.api.grpc:proto-google-common-protos:1.18.0" + } + + object Grpc { + private const val group = "io.grpc" + + const val api = "$group:grpc-api" + + const val core = "$group:grpc-core" + + const val stub = "$group:grpc-stub" + + const val netty = "$group:grpc-netty" + + const val proto = "$group:grpc-protobuf" + } + + object Maven { + private const val group = "org.apache.maven" + + const val resolver = "$group:maven-resolver-provider" + + const val resolverConnector = "$group.resolver:maven-resolver-connector-basic" + + const val resolverWagon = "$group.resolver:maven-resolver-transport-wagon" + + const val wagonFile = "$group.wagon:wagon-file" + + const val wagonHttp = "$group.wagon:wagon-http" + } + + const val elastic5 = "org.elasticsearch.client:transport" + + const val mysql = "mysql:mysql-connector-java" + + const val postgresql = "org.postgresql:postgresql" + + const val junit = "org.junit.jupiter:junit-jupiter" + + const val hbase = "com.aliyun.hbase:alihbase-client" + + const val reflections = "org.reflections:reflections" + + const val jooq = "org.jooq:jooq" + + const val hikari = "com.zaxxer:HikariCP" + + const val h2 = "com.h2database:h2" + + const val protoc = "com.github.os72:protoc-jar" + + const val nettyTcnative = "io.netty:netty-tcnative-boringssl-static" + + const val retrofit = "com.squareup.retrofit2:retrofit" + + const val okhttp = "com.squareup.okhttp3:okhttp" + + const val resilience4j = "io.github.resilience4j:resilience4j-retrofit" + + const val lettuce = "io.lettuce:lettuce-core" + + const val antlr4 = "org.antlr:antlr4:4.8" + + const val swagger = "io.swagger.core.v3:swagger-core" +} + +val Project.managedDependencies: Project + get() { + dependencies { + add("implementation", platform(project(":sisyphus-dependencies"))) + } + + return this + } \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/kotlin.kt b/buildSrc/src/main/kotlin/kotlin.kt new file mode 100644 index 00000000..e472e408 --- /dev/null +++ b/buildSrc/src/main/kotlin/kotlin.kt @@ -0,0 +1,27 @@ +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +val Project.kotlin: Project + get() { + apply { + plugin("kotlin") + plugin("kotlin-spring") + plugin("io.gitlab.arturbosch.detekt") + plugin("org.jlleitschuh.gradle.ktlint") + } + + dependencies { + add("api", Dependencies.Kotlin.stdlib) + add("api", Dependencies.Kotlin.reflect) + add("api", Dependencies.Kotlin.Coroutines.core) + } + + tasks.withType { + kotlinOptions.jvmTarget = "1.8" + kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn" + } + + return this + } \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/plugin.kt b/buildSrc/src/main/kotlin/plugin.kt new file mode 100644 index 00000000..ffea1b67 --- /dev/null +++ b/buildSrc/src/main/kotlin/plugin.kt @@ -0,0 +1,11 @@ +inline val org.gradle.plugin.use.PluginDependenciesSpec.protobuf: org.gradle.plugin.use.PluginDependencySpec + get() = id("sisyphus.protobuf") + +inline val org.gradle.plugin.use.PluginDependenciesSpec.sisyphus: org.gradle.plugin.use.PluginDependencySpec + get() = id("sisyphus.project") + +inline val org.gradle.plugin.use.PluginDependenciesSpec.publish: org.gradle.plugin.use.PluginDependencySpec + get() { + id("nebula.maven-base-publish") + return id("nebula.source-jar") + } diff --git a/buildSrc/src/main/kotlin/project.kt b/buildSrc/src/main/kotlin/project.kt new file mode 100644 index 00000000..562d8e08 --- /dev/null +++ b/buildSrc/src/main/kotlin/project.kt @@ -0,0 +1,69 @@ +import com.bybutter.sisyphus.project.gradle.SisyphusProjectPlugin +import com.github.benmanes.gradle.versions.VersionsPlugin +import org.gradle.api.Project +import org.gradle.api.plugins.JavaLibraryPlugin +import org.gradle.api.tasks.testing.Test +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.withType +import org.gradle.plugins.ide.idea.IdeaPlugin + +val Project.next: Project + get() { + pluginManager.apply(JavaLibraryPlugin::class.java) + pluginManager.apply(IdeaPlugin::class.java) + pluginManager.apply(VersionsPlugin::class.java) + pluginManager.apply(SisyphusProjectPlugin::class.java) + + kotlin.managedDependencies + + dependencies { + add("testImplementation", Dependencies.junit) + } + + tasks.withType { + useJUnitPlatform() + } + return this + } + +val Project.middleware: Project + get() { + next + + group = "com.bybutter.sisyphus.middleware" + + dependencies { + add("api", Dependencies.Spring.Boot.boot) + add("testImplementation", Dependencies.Spring.Boot.test) + } + return this + } + +val Project.lib: Project + get() { + next + + group = "com.bybutter.sisyphus" + return this + } + +val Project.starter: Project + get() { + next + + group = "com.bybutter.sisyphus.starter" + + dependencies { + add("api", Dependencies.Spring.Boot.boot) + add("testImplementation", Dependencies.Spring.Boot.test) + } + return this + } + +val Project.tools: Project + get() { + next + + group = "com.bybutter.sisyphus.tools" + return this + } \ No newline at end of file diff --git a/dependencies/build.gradle.kts b/dependencies/build.gradle.kts new file mode 100644 index 00000000..8739ed94 --- /dev/null +++ b/dependencies/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + `java-platform` + id("nebula.maven-publish") + id("sisyphus.project") +} + +group = "com.bybutter.sisyphus" + +javaPlatform { + allowDependencies() +} + +dependencies { + api(platform(project(":sisyphus-bom"))) + api(platform("org.springframework.boot:spring-boot-dependencies:2.2.7.RELEASE")) + api(platform("org.jetbrains.kotlin:kotlin-bom:1.3.70")) + api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.3.6")) + api(platform("org.apache.maven:maven:3.6.3")) + api(platform("io.grpc:grpc-bom:1.29.0")) + api(platform("com.google.protobuf:protobuf-bom:3.11.4")) + + constraints { + api("com.squareup:kotlinpoet:1.5.0") + api("org.elasticsearch.client:transport:5.6.3") + api("com.aliyun.hbase:alihbase-client:2.0.3") + api("org.reflections:reflections:0.9.11") + api("com.github.os72:protoc-jar:3.11.4") + api("io.netty:netty-tcnative-boringssl-static:2.0.20.Final") + api("org.apache.maven.wagon:wagon-http:3.3.4") + api("org.junit.jupiter:junit-jupiter:5.5.1") + api("org.reflections:reflections:0.9.11") + api("com.squareup.okhttp3:okhttp:4.2.2") + api("com.squareup.retrofit2:retrofit:2.7.1") + api("io.github.resilience4j:resilience4j-retrofit:1.3.1") + api("org.antlr:antlr4:4.8") + api("io.swagger.core.v3:swagger-core:2.1.1") + api("org.jooq:jooq:3.13.1") + api("com.google.api.grpc:proto-google-common-protos:1.18.0") + } +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..1948b907 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..6623300b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 00000000..cccdd3d5 --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..f9553162 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lib/sisyphus-common/build.gradle.kts b/lib/sisyphus-common/build.gradle.kts new file mode 100644 index 00000000..ac938e0a --- /dev/null +++ b/lib/sisyphus-common/build.gradle.kts @@ -0,0 +1,9 @@ +lib + +plugins { + `java-library` +} + +dependencies { + compileOnly(Dependencies.Spring.Boot.boot) +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/collection/BiMap.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/collection/BiMap.kt new file mode 100644 index 00000000..2294b9af --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/collection/BiMap.kt @@ -0,0 +1,296 @@ +package com.bybutter.sisyphus.collection + +interface BiMap : Map { + override val values: Set + val inverse: BiMap +} + +interface MutableBiMap : BiMap, MutableMap { + override val values: MutableSet + override val inverse: MutableBiMap +} + +abstract class AbstractBiMap protected constructor( + private val direct: MutableMap, + private val reverse: MutableMap +) : MutableBiMap { + override val size: Int + get() = direct.size + + override val inverse: MutableBiMap by lazy { + object : AbstractBiMap(reverse, direct) { + override val inverse: MutableBiMap + get() = this@AbstractBiMap + } + } + + override val entries: MutableSet> = + BiMapSet(direct.entries, { it.key }, { BiMapEntry(it) }) + + override val keys: MutableSet + get() = BiMapSet(direct.keys, { it }, { it }) + + override val values: MutableSet + get() = inverse.keys + + override fun put(key: K, value: V): V? { + val oldValue = direct.put(key, value) + oldValue?.let { reverse.remove(it) } + val oldKey = reverse.put(value, key) + oldKey?.let { direct.remove(it) } + return oldValue + } + + override fun putAll(from: Map) { + from.entries.forEach { put(it.key, it.value) } + } + + override fun remove(key: K): V? { + val oldValue = direct.remove(key) + oldValue?.let { reverse.remove(it) } + return oldValue + } + + override fun clear() { + direct.clear() + reverse.clear() + } + + override fun get(key: K): V? { + return direct[key] + } + + override fun containsKey(key: K): Boolean { + return key in direct + } + + override fun containsValue(value: V): Boolean { + return value in reverse + } + + override fun isEmpty(): Boolean { + return direct.isEmpty() + } + + private inner class BiMapSet( + private val elements: MutableSet, + private val keyGetter: (T) -> K, + private val elementWrapper: (T) -> T + ) : MutableSet by elements { + override fun remove(element: T): Boolean { + if (element !in this) { + return false + } + + val key = keyGetter(element) + val value = direct.remove(key) ?: return false + try { + reverse.remove(value) + } catch (throwable: Throwable) { + direct.put(key, value) + throw throwable + } + return true + } + + override fun clear() { + direct.clear() + reverse.clear() + } + + override fun iterator(): MutableIterator { + val iterator = elements.iterator() + return BiMapSetIterator(iterator, keyGetter, elementWrapper) + } + } + + private inner class BiMapSetIterator( + private val iterator: MutableIterator, + private val keyGetter: (T) -> K, + private val elementWrapper: (T) -> T + ) : MutableIterator { + private var last: T? = null + + override fun hasNext(): Boolean { + return iterator.hasNext() + } + + override fun next(): T { + val element = iterator.next().apply { + last = this + } + return elementWrapper(element) + } + + override fun remove() { + last ?: throw NullPointerException("Move to an element before removing it") + try { + val key = keyGetter(last!!) + val value = direct[key] ?: error("BiMap doesn't contain key $key ") + reverse.remove(value) + try { + iterator.remove() + } catch (throwable: Throwable) { + reverse[value] = key + throw throwable + } + } finally { + last = null + } + } + } + + private inner class BiMapEntry( + private val entry: MutableMap.MutableEntry + ) : MutableMap.MutableEntry by entry { + override fun setValue(newValue: V): V { + if (entry.value == newValue) { + reverse[newValue] = entry.key + try { + return entry.setValue(newValue) + } catch (throwable: Throwable) { + reverse[entry.value] = entry.key + throw throwable + } + } else { + check(newValue !in reverse) { "BiMap already contains value $newValue" } + reverse[newValue] = entry.key + try { + return entry.setValue(newValue) + } catch (throwable: Throwable) { + reverse.remove(newValue) + throw throwable + } + } + } + } +} + +class HashBiMap(capacity: Int = 16) : AbstractBiMap(HashMap(capacity), HashMap(capacity)) + +class LinkedHashBiMap(capacity: Int = 16) : AbstractBiMap(LinkedHashMap(capacity), LinkedHashMap(capacity)) + +object EmptyBiMap : BiMap { + override val values: Set = setOf() + override val inverse: BiMap = EmptyBiMap + override val entries: Set> = setOf() + override val keys: Set = setOf() + override val size: Int = 0 + + override fun containsKey(key: Any): Boolean { + return false + } + + override fun containsValue(value: Any): Boolean { + return false + } + + override fun get(key: Any): Any? { + return null + } + + override fun isEmpty(): Boolean { + return true + } +} + +@Suppress("UNCHECKED_CAST") +fun emptyBiMapOf(): BiMap { + return EmptyBiMap as BiMap +} + +fun biMapOf(): BiMap { + return emptyBiMapOf() +} + +fun biMapOf(vararg pairs: Pair): BiMap { + return LinkedHashBiMap().apply { + this.putAll(pairs) + } +} + +fun biMapOf(map: Map): BiMap { + return LinkedHashBiMap().apply { + this.putAll(map) + } +} + +fun biMapOf(map: Iterable>): BiMap { + return LinkedHashBiMap().apply { + this.putAll(map) + } +} + +fun Map.toBiMap(): BiMap { + return biMapOf(this) +} + +fun mutableBiMapOf(): MutableBiMap { + return LinkedHashBiMap() +} + +fun mutableBiMapOf(vararg pairs: Pair): MutableBiMap { + return LinkedHashBiMap().apply { + this.putAll(pairs) + } +} + +fun mutableBiMapOf(map: Map): MutableBiMap { + return LinkedHashBiMap().apply { + this.putAll(map) + } +} + +fun mutableBiMapOf(map: Iterable>): MutableBiMap { + return LinkedHashBiMap().apply { + this.putAll(map) + } +} + +fun Map.toMutableBiMap(): MutableBiMap { + return mutableBiMapOf(this) +} + +fun linkedBiMapOf(): LinkedHashBiMap { + return LinkedHashBiMap() +} + +fun linkedBiMapOf(vararg pairs: Pair): LinkedHashBiMap { + return LinkedHashBiMap().apply { + this.putAll(pairs) + } +} + +fun linkedBiMapOf(map: Map): LinkedHashBiMap { + return LinkedHashBiMap().apply { + this.putAll(map) + } +} + +fun linkedBiMapOf(map: Iterable>): LinkedHashBiMap { + return LinkedHashBiMap().apply { + this.putAll(map) + } +} + +fun hashBiMapOf(): HashBiMap { + return HashBiMap() +} + +fun hashBiMapOf(vararg pairs: Pair): HashBiMap { + return HashBiMap().apply { + this.putAll(pairs) + } +} + +fun hashBiMapOf(map: Map): HashBiMap { + return HashBiMap().apply { + this.putAll(map) + } +} + +fun hashBiMapOf(map: Iterable>): HashBiMap { + return HashBiMap().apply { + this.putAll(map) + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/collection/Collections.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/collection/Collections.kt new file mode 100644 index 00000000..42047fd9 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/collection/Collections.kt @@ -0,0 +1,115 @@ +package com.bybutter.sisyphus.collection + +/** + * Ensure a list is mutable, if the list is mutable already, do nothing, otherwise convert it to [MutableList] + */ +operator fun List.unaryPlus(): MutableList { + return if (this is MutableList) { + this + } else { + this.toMutableList() + } +} + +/** + * Ensure a map is mutable, if the map is mutable already, do nothing, otherwise convert it to [MutableMap] + */ +operator fun Map.unaryPlus(): MutableMap { + return if (this is MutableMap) { + this + } else { + this.toMutableMap() + } +} + +fun MutableList.addNotNull(value: T?): MutableList { + value ?: return this + this += value + return this +} + +fun MutableList.addAllNotNull(vararg values: T?): MutableList { + for (value in values) { + value ?: continue + this += value + } + return this +} + +fun MutableList.addAllNotNull(values: Iterable): MutableList { + for (value in values) { + value ?: continue + this += value + } + return this +} + +/** + * Ensure a set is mutable, if the set is mutable already, do nothing, otherwise convert it to [MutableSet] + */ +operator fun Set.unaryPlus(): MutableSet { + return if (this is MutableSet) { + this + } else { + this.toMutableSet() + } +} + +fun List.contentEquals(other: List?): Boolean { + other ?: return false + + if (size != other.size) { + return false + } + + for ((index, parameter) in withIndex()) { + if (parameter != other[index]) { + return false + } + } + + return true +} + +fun Map.contentEquals(other: Map?): Boolean { + other ?: return false + + if (size != other.size) { + return false + } + + if (!keys.containsAll(other.keys)) return false + if (!other.keys.containsAll(keys)) return false + if (keys.any { this[it] != other[it] }) return false + + return true +} + +fun Iterable.firstNotNull(block: (T) -> R?): R? { + for (value in this) { + return block(value) ?: continue + } + return null +} + +fun Iterable.firstNotNullOrDefault(default: R, block: (T) -> R?): R { + for (value in this) { + return block(value) ?: continue + } + return default +} + +fun MutableIterable.takeWhen(block: (T) -> Boolean): List { + val iterator = this.iterator() + val result = mutableListOf() + + while (iterator.hasNext()) { + val element = iterator.next() + if (block(element)) { + result += element + iterator.remove() + } + } + + return result +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/collection/Iterator.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/collection/Iterator.kt new file mode 100644 index 00000000..418d9c51 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/collection/Iterator.kt @@ -0,0 +1,32 @@ +package com.bybutter.sisyphus.collection + +import java.util.Enumeration + +private class IterableEnumeration(iterator: Iterator) : Enumeration, Iterator by iterator { + override fun hasMoreElements(): Boolean { + return hasNext() + } + + override fun nextElement(): T { + return next() + } + + @Suppress("UNCHECKED_CAST") + override fun asIterator(): MutableIterator { + return this as MutableIterator + } +} + +/** + * Make a [Enumeration] from [Iterator]. + */ +fun Iterator.asEnumeration(): Enumeration { + return IterableEnumeration(this) +} + +/** + * Make a [Enumeration] from [Iterable]. + */ +fun Iterable.asEnumeration(): Enumeration { + return IterableEnumeration(this.iterator()) +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/coroutine/Corotines.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/coroutine/Corotines.kt new file mode 100644 index 00000000..465e0f5d --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/coroutine/Corotines.kt @@ -0,0 +1,12 @@ +package com.bybutter.sisyphus.coroutine + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Run task in [Dispatchers.IO] scope. + */ +suspend fun io(block: suspend CoroutineScope.() -> T): T { + return withContext(Dispatchers.IO, block) +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/BitStream.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/BitStream.kt new file mode 100644 index 00000000..9051ef41 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/BitStream.kt @@ -0,0 +1,171 @@ +package com.bybutter.sisyphus.data + +import java.io.InputStream +import kotlin.experimental.and +import kotlin.experimental.inv +import kotlin.experimental.or + +class BitInputStream(private val source: InputStream) : InputStream() { + private var pos = 8 + private var byte: Int = 0 + + override fun read(): Int { + if (pos > 7) { + byte = source.read() + if (byte == -1) { + return -1 + } + pos = 0 + } + + return if (byte and (1 shl pos++) > 0) { + 1 + } else { + 0 + } + } + + fun readBits(byteArray: ByteArray, bits: Int): Int { + if (bits > byteArray.size * 8) { + throw IllegalArgumentException() + } + + var read = 0 + for (i in 0 until bits) { + if (pos > 7) { + byte = source.read() + if (byte == -1) { + break + } + pos = 0 + } + + read++ + if (byte and (1 shl pos++) > 0) { + byteArray[i / 8] = byteArray[i / 8] or (1 shl (i % 8)).toByte() + } else { + byteArray[i / 8] = byteArray[i / 8] and (1 shl (i % 8)).toByte().inv() + } + } + + return read + } + + override fun available(): Int { + return super.available() * 8 + (8 - pos) + } + + override fun close() { + source.close() + } + + override fun reset() { + source.reset() + } + + override fun skip(n: Long): Long { + val oldPos = pos + pos += n.toInt() + var skipped = 0 + while (pos > 7) { + pos -= 8 + byte = source.read() + if (byte == -1) { + break + } + skipped += 8 + } + + return pos.toLong() - oldPos + skipped + } +} + +class BitBuffer(private val data: ByteArray) { + private var pos = 0 + + fun read(): Int { + if (pos >= data.size * 8) { + return -1 + } + + return if (data[pos / 8] and (1 shl (pos % 8)).toByte() > 0) { + 1 + } else { + 0 + } + } + + fun write(value: Int): Int { + if (pos >= data.size * 8) { + return 0 + } + + if (value > 0) { + data[pos / 8] = data[pos / 8] or (1 shl (pos % 8)).toByte() + } else { + data[pos / 8] = data[pos / 8] and (1 shl (pos % 8)).toByte().inv() + } + pos++ + return 1 + } + + fun readBits(byteArray: ByteArray, bits: Int): Int { + if (bits > byteArray.size * 8) { + throw IllegalArgumentException() + } + + var read = 0 + for (i in 0 until bits) { + if (pos >= data.size * 8) { + break + } + + read++ + if (data[pos / 8] and (1 shl (pos % 8)).toByte() > 0) { + byteArray[i / 8] = byteArray[i / 8] or (1 shl (i % 8)).toByte() + } else { + byteArray[i / 8] = byteArray[i / 8] and (1 shl (i % 8)).toByte().inv() + } + pos++ + } + + return read + } + + fun writeBits(byteArray: ByteArray, bits: Int): Int { + if (bits > byteArray.size * 8) { + throw IllegalArgumentException() + } + + var written = 0 + for (i in 0 until bits) { + if (pos >= data.size * 8) { + break + } + + written++ + if (byteArray[i / 8] and (1 shl (i % 8)).toByte() > 0) { + data[pos / 8] = data[pos / 8] or (1 shl (pos % 8)).toByte() + } else { + data[pos / 8] = data[pos / 8] and (1 shl (pos % 8)).toByte().inv() + } + pos++ + } + + return written + } + + fun seek(offset: Int): Int { + pos += offset + if (pos < 0) { + pos = 0 + } else if (pos > data.size * 8) { + pos = data.size * 8 + } + return pos + } + + fun toByteArray(): ByteArray { + return data + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/ByteArrays.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/ByteArrays.kt new file mode 100644 index 00000000..9f7f4fb4 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/ByteArrays.kt @@ -0,0 +1,113 @@ +package com.bybutter.sisyphus.data + +import java.nio.ByteBuffer + +fun ByteArray.hash(): Int { + return this.contentHashCode() +} + +fun ByteArray.eq(other: ByteArray): Boolean { + return this.contentEquals(other) +} + +fun ByteArray.hashWrapper(): ByteArrayHashingWrapper { + return ByteArrayHashingWrapper(this) +} + +fun Boolean.toByteData(): ByteArray { + return if (this) byteArrayOf(1) else byteArrayOf(0) +} + +fun Byte.toByteData(): ByteArray { + return byteArrayOf(this) +} + +fun Short.toByteData(): ByteArray { + return ByteBuffer.allocate(2).apply { + putShort(this@toByteData) + }.array() +} + +fun Int.toByteData(): ByteArray { + return ByteBuffer.allocate(4).apply { + putInt(this@toByteData) + }.array() +} + +fun Long.toByteData(): ByteArray { + return ByteBuffer.allocate(8).apply { + putLong(this@toByteData) + }.array() +} + +fun Float.toByteData(): ByteArray { + return ByteBuffer.allocate(4).apply { + putFloat(this@toByteData) + }.array() +} + +fun Double.toByteData(): ByteArray { + return ByteBuffer.allocate(8).apply { + putDouble(this@toByteData) + }.array() +} + +fun ByteArray.toBoolean(): Boolean { + if (this.size != 1) throw IllegalArgumentException("Data array too big to convert to 'byte'.") + return this[0].toInt() != 0 +} + +fun ByteArray.toByte(): Byte { + if (this.size != 1) throw IllegalArgumentException("Data array too big to convert to 'byte'.") + return this[0] +} + +fun ByteArray.toShort(): Short { + return ByteBuffer.allocate(2).apply { + put(this@toShort) + }.getShort(0) +} + +fun ByteArray.toInt(): Int { + return ByteBuffer.allocate(4).apply { + put(this@toInt) + }.getInt(0) +} + +fun ByteArray.toLong(): Long { + return ByteBuffer.allocate(8).apply { + put(this@toLong) + }.getLong(0) +} + +fun ByteArray.toFloat(): Float { + return ByteBuffer.allocate(4).apply { + put(this@toFloat) + }.getFloat(0) +} + +fun ByteArray.toDouble(): Double { + return ByteBuffer.allocate(8).apply { + put(this@toDouble) + }.getDouble(0) +} + +fun ByteArray.wrapTo(size: Int): ByteArray { + if (this.size < size) { + return ByteArray(size - this.size) + this + } + return this +} + +class ByteArrayHashingWrapper(val target: ByteArray) { + override fun hashCode(): Int { + return target.hash() + } + + override fun equals(other: Any?): Boolean { + if (other !is ByteArrayHashingWrapper) { + return false + } + return target.eq(other.target) + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/GZip.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/GZip.kt new file mode 100644 index 00000000..fabf1d4d --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/GZip.kt @@ -0,0 +1,30 @@ +package com.bybutter.sisyphus.data + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +/** + * Compress data with gZip algorithm. + */ +fun ByteArray.gzip(): ByteArray { + ByteArrayOutputStream().use { + GZIPOutputStream(it).use { gzip -> + gzip.write(this) + } + it.flush() + return it.toByteArray() + } +} + +/** + * Decompress data with gZip algorithm. + */ +fun ByteArray.ungzip(): ByteArray { + ByteArrayInputStream(this).use { + GZIPInputStream(it).use { gzip -> + return gzip.readBytes() + } + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/Hex.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/Hex.kt new file mode 100644 index 00000000..083e99fb --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/Hex.kt @@ -0,0 +1,44 @@ +package com.bybutter.sisyphus.data + +import kotlin.experimental.or + +private val hexArray = "0123456789ABCDEF".toCharArray() +private val hexChars = "0123456789ABCDEFabcdef".toSet() + +/** + * Encode data to hex string. + */ +fun ByteArray.hex(): String { + val hexChars = CharArray(this.size * 2) + for (index in this.indices) { + val v = 0xFF and this[index].toInt() + hexChars[index * 2] = hexArray[v.ushr(4)] + hexChars[index * 2 + 1] = hexArray[v and 0x0F] + } + return String(hexChars) +} + +/** + * Decode hex string to data. + */ +fun String.parseHex(): ByteArray { + if (this.isEmpty()) return byteArrayOf() + + val startIndex = this.length % 2 + val result = ByteArray(this.length / 2 + startIndex) + + for (index in this.indices) { + val offset = index + startIndex + if (!hexChars.contains(this[index])) { + throw IllegalArgumentException("Wrong hex format char '${this[index]}'.") + } + val value = when { + this[index] > 'a' -> 10 + (this[index] - 'a') + this[index] > 'A' -> 10 + (this[index] - 'A') + else -> this[index] - '0' + } shl ((1 - offset % 2) * 4) + result[offset / 2] = result[offset / 2] or value.toByte() + } + + return result +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/Random.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/Random.kt new file mode 100644 index 00000000..01e6f160 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/Random.kt @@ -0,0 +1,11 @@ +package com.bybutter.sisyphus.data + +import kotlin.random.Random + +fun randomByteArray(length: Int): ByteArray { + return Random.nextBytes(length) +} + +fun randomByteArray(array: ByteArray, from: Int = 0, to: Int = array.size): ByteArray { + return Random.nextBytes(array, from, to) +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/UrlEncoding.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/UrlEncoding.kt new file mode 100644 index 00000000..6a931717 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/UrlEncoding.kt @@ -0,0 +1,13 @@ +package com.bybutter.sisyphus.data + +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.Charset + +fun String.urlEncode(charset: Charset = Charsets.UTF_8): String { + return URLEncoder.encode(this, charset) +} + +fun String.urlDecode(charset: Charset = Charsets.UTF_8): String { + return URLDecoder.decode(this, charset) +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/Varint.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/Varint.kt new file mode 100644 index 00000000..56d744ce --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/data/Varint.kt @@ -0,0 +1,147 @@ +package com.bybutter.sisyphus.data + +import java.io.ByteArrayOutputStream +import java.io.InputStream + +/** + * Encode int to Varint. + */ +fun Int.toVarint(): ByteArray { + val sink = ByteArray(varintSize) + var v = this + var offset = 0 + + do { + // Encode next 7 bits + terminator bit + val bits = v and 0x7F + v = v ushr 7 + val b = (bits + (if (v != 0) 0x80 else 0)).toByte() + sink[offset++] = b + } while (v != 0) + + return sink +} + +/** + * Encode long to Varint. + */ +fun Long.toVarint(): ByteArray { + val sink = ByteArray(varintSize) + var v = this + var offset = 0 + + do { + // Encode next 7 bits + terminator bit + val bits = v and 0x7F + v = v ushr 7 + val b = (bits + (if (v != 0L) 0x80 else 0)).toByte() + sink[offset++] = b + } while (v != 0L) + + return sink +} + +/** + * Decode Varint to int. + */ +fun ByteArray.toVarint32(): Int { + var offset = 0 + var result = 0 + var shift = 0 + var b: Int + do { + if (shift >= 32) { + // Out of range + throw IndexOutOfBoundsException("varint too long.") + } + // Get 7 bits from next byte + b = this[offset++].toInt() + result = result or (b and 0x7F shl shift) + shift += 7 + } while (b and 0x80 != 0) + return result +} + +/** + * Decode Varint to long. + */ +fun ByteArray.toVarint64(): Long { + var offset = 0 + var result = 0L + var shift = 0 + var b: Long + do { + if (shift >= 64) { + // Out of range + throw IndexOutOfBoundsException("varint too long.") + } + // Get 7 bits from next byte + b = this[offset++].toLong() + result = result or (b and 0x7F shl shift) + shift += 7 + } while (b and 0x80 != 0L) + return result +} + +val Int.varintSize: Int + get() { + var n = this + var size = 0 + do { + size++ + n = n ushr 7 + } while (n != 0) + return size + } + +val Long.varintSize: Int + get() { + var n = this + var size = 0 + do { + size++ + n = n ushr 7 + } while (n != 0L) + return size + } + +fun Int.toZigZagVarint(): ByteArray { + return this.encodeZigZag().toVarint() +} + +fun Long.toZigZagVarint(): ByteArray { + return this.encodeZigZag().toVarint() +} + +fun ByteArray.toZigZagVarint32(): Int { + return this.toVarint32().decodeZigZag() +} + +fun ByteArray.toZigZagVarint64(): Long { + return this.toVarint64().decodeZigZag() +} + +fun InputStream.readRawVarintData(): ByteArray { + val output = ByteArrayOutputStream() + do { + val t = this.read() + output.write(t) + } while (t and 0x80 > 0) + return output.toByteArray() +} + +fun Int.decodeZigZag(): Int { + return (this ushr 1) xor -(this and 1) +} + +fun Long.decodeZigZag(): Long { + return (this ushr 1) xor -(this and 1) +} + +fun Int.encodeZigZag(): Int { + return (this shl 1) xor (this shr 31) +} + +fun Long.encodeZigZag(): Long { + return (this shl 1) xor (this shr 63) +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/io/Path.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/io/Path.kt new file mode 100644 index 00000000..d05139f7 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/io/Path.kt @@ -0,0 +1,31 @@ +package com.bybutter.sisyphus.io + +import java.io.File + +/** + * Convert path to unix style path, it will replace all '\' to '/'. + */ +fun String.toUnixPath(): String { + return this.replace('\\', '/') +} + +/** + * Convert path to windows style path, it will replace all '/' to '\'. + */ +fun String.toWindowsPath(): String { + return this.replace('/', '\\') +} + +/** + * Convert path to current os style path, it will replace all '/' and '\' to [File.separatorChar]. + */ +fun String.toPlatformPath(): String { + return buildString { + for (ch in this@toPlatformPath) { + append(when (ch) { + '\\', '/' -> File.separatorChar + else -> ch + }) + } + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/BaseWildcardType.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/BaseWildcardType.kt new file mode 100644 index 00000000..8acd7f01 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/BaseWildcardType.kt @@ -0,0 +1,20 @@ +package com.bybutter.sisyphus.reflect + +import com.bybutter.sisyphus.collection.contentEquals +import java.lang.reflect.WildcardType + +abstract class BaseWildcardType(protected val target: List) : JvmType(), WildcardType { + + override fun equals(other: Any?): Boolean { + return this.javaClass == other?.javaClass && target.contentEquals((other as? BaseWildcardType)?.target) + } + + override fun hashCode(): Int { + var base = this.javaClass.hashCode() + for (parameter in target) { + val hash = parameter.hashCode() + base = (base xor hash) + hash + } + return base + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/GenericType.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/GenericType.kt new file mode 100644 index 00000000..ffca39e9 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/GenericType.kt @@ -0,0 +1,49 @@ +package com.bybutter.sisyphus.reflect + +import com.bybutter.sisyphus.collection.contentEquals +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type +import java.util.concurrent.ConcurrentHashMap + +class GenericType private constructor(raw: Class<*>, private val parameters: List) : SimpleType(raw), + ParameterizedType { + companion object { + private val cache = ConcurrentHashMap() + + operator fun invoke(raw: Class<*>, parameters: List): GenericType { + return cache.getOrPut("${raw.typeName}<${parameters.joinToString(", ") { it.typeName }}>") { + GenericType(raw, parameters) + } + } + } + + override fun getRawType(): Type { + return raw + } + + override fun getOwnerType(): Type { + return raw.declaringClass + } + + override fun getActualTypeArguments(): Array { + return parameters.toTypedArray() + } + + override fun equals(other: Any?): Boolean { + if (other !is GenericType) return false + return raw == other.raw && parameters.contentEquals(other.parameters) + } + + override fun hashCode(): Int { + var base = super.hashCode() + for (parameter in parameters) { + val hash = parameter.hashCode() + base = (base xor hash) + hash + } + return base + } + + override fun toString(): String { + return "${raw.typeName}<${parameters.joinToString(", ") { it.typeName }}>" + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/InWildcardType.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/InWildcardType.kt new file mode 100644 index 00000000..102e112d --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/InWildcardType.kt @@ -0,0 +1,28 @@ +package com.bybutter.sisyphus.reflect + +import java.lang.reflect.Type +import java.util.concurrent.ConcurrentHashMap + +class InWildcardType private constructor(target: List) : BaseWildcardType(target) { + companion object { + private val cache = ConcurrentHashMap() + + operator fun invoke(target: List): InWildcardType { + return cache.getOrPut(target.asSequence().map { it.typeName }.sorted().joinToString()) { + InWildcardType(target) + } + } + } + + override fun getLowerBounds(): Array { + return target.toTypedArray() + } + + override fun getUpperBounds(): Array { + return arrayOf() + } + + override fun toString(): String { + return "? super ${target.joinToString(" & ") { it.typeName }}" + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/JvmType.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/JvmType.kt new file mode 100644 index 00000000..42e10011 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/JvmType.kt @@ -0,0 +1,100 @@ +package com.bybutter.sisyphus.reflect + +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type +import java.lang.reflect.WildcardType + +/** + * Type to describe a JVM type. + * + * @see SimpleType + * @see GenericType + * @see InWildcardType + * @see OutWildcardType + */ +abstract class JvmType protected constructor() : Type { + companion object { + fun fromType(type: Type): JvmType { + return when (type) { + is JvmType -> { + type + } + is Class<*> -> { + SimpleType(type) + } + is ParameterizedType -> { + GenericType(type.rawType as Class<*>, type.actualTypeArguments.map { fromType(it) }) + } + is WildcardType -> { + if (type.lowerBounds.isNotEmpty()) { + InWildcardType(type.lowerBounds.map { fromType(it) as SimpleType }) + } else { + OutWildcardType(type.upperBounds.map { fromType(it) as SimpleType }) + } + } + else -> { + fromName(type.typeName) + } + } + } + + fun fromName(name: String): JvmType { + return when { + name == "?" -> OutWildcardType.STAR + name.startsWith("? extends ") -> OutWildcardType(name.substringAfter("? extends ").split(" & ").map { it.toType() as SimpleType }) + name.startsWith("? super ") -> InWildcardType(name.substringAfter("? extends ").split(" & ").map { it.toType() as SimpleType }) + name.indexOf('<') > 0 -> { + val raw = Class.forName(name.substringBefore('<')) + val parameters = name.substring(name.indexOf('<') + 1, name.lastIndexOf('>')) + GenericType(raw, splitWithLayer(parameters).map { it.toType() }) + } + else -> SimpleType(Class.forName(name)) + } + } + + private fun splitWithLayer( + value: String, + splitter: String = ", ", + upLayer: String = "<", + downLayer: String = ">" + ): List { + var layer = 0 + var index = 0 + val builder = StringBuilder() + val result = mutableListOf() + + while (index < value.length) { + if (layer < 0) { + throw IllegalStateException("Wrong generic type format.") + } + + if (value.startsWith(splitter, index) && layer == 0) { + result.add(builder.toString()) + builder.clear() + index += splitter.length + continue + } + + if (value.startsWith(upLayer, index)) { + builder.append(upLayer) + layer++ + index += upLayer.length + continue + } + + if (value.startsWith(downLayer, index)) { + builder.append(downLayer) + layer-- + index += downLayer.length + continue + } + + builder.append(value[index]) + index++ + } + + result.add(builder.toString()) + return result + } + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/OutWildcardType.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/OutWildcardType.kt new file mode 100644 index 00000000..a2a4bd28 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/OutWildcardType.kt @@ -0,0 +1,34 @@ +package com.bybutter.sisyphus.reflect + +import java.lang.reflect.Type +import java.util.concurrent.ConcurrentHashMap + +class OutWildcardType private constructor(target: List) : BaseWildcardType(target) { + companion object { + private val cache = ConcurrentHashMap() + + operator fun invoke(target: List): OutWildcardType { + return cache.getOrPut(target.asSequence().map { it.typeName }.sorted().joinToString()) { + OutWildcardType(target) + } + } + + val STAR = invoke(listOf()) + } + + override fun getLowerBounds(): Array { + return arrayOf() + } + + override fun getUpperBounds(): Array { + return target.toTypedArray() + } + + override fun toString(): String { + if (target.isEmpty() || target[0] == SimpleType(Any::class.java)) { + return "?" + } + + return "? extends ${target.joinToString(" & ") { it.typeName }}" + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/Reflect.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/Reflect.kt new file mode 100644 index 00000000..f134e4ad --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/Reflect.kt @@ -0,0 +1,200 @@ +package com.bybutter.sisyphus.reflect + +import java.lang.reflect.Modifier +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type +import java.lang.reflect.TypeVariable +import java.lang.reflect.WildcardType +import kotlin.reflect.KClass +import kotlin.reflect.KParameter +import kotlin.reflect.KProperty1 +import kotlin.reflect.KType +import kotlin.reflect.KTypeParameter +import kotlin.reflect.KTypeProjection +import kotlin.reflect.full.createType +import kotlin.reflect.full.isSubclassOf +import kotlin.reflect.full.memberProperties +import kotlin.reflect.full.superclasses + +private fun KType.getTypeArgument(target: KClass<*>, index: Int, map: MutableMap): KType? { + val raw = when (val kClass = this.classifier) { + is KClass<*> -> { + if (!kClass.isSubclassOf(target) && kClass != target) { + return null + } + + for ((i, parameter) in kClass.typeParameters.withIndex()) { + map[parameter] = this.arguments[i].type!! + } + kClass + } + else -> throw UnsupportedOperationException("Unsupported '$this'.") + } + + if (raw == target) { + var result = map[target.typeParameters[index]]!! + var resultClassifier = result.classifier + while (resultClassifier is KTypeParameter) { + result = map[resultClassifier]!! + resultClassifier = result.classifier + } + return result + } + + return raw.supertypes.map { + it.getTypeArgument(target, index, map) + }.firstOrNull { it != null } +} + +fun KType.getTypeArgument(target: KClass<*>, index: Int): KType { + return this.getTypeArgument(target, index, mutableMapOf()) ?: throw IllegalArgumentException() +} + +private fun Type.getTypeArgument(target: Class, index: Int, map: MutableMap): Type? { + val raw = when (this) { + is ParameterizedType -> { + for ((i, parameter) in (this.rawType as Class<*>).typeParameters.withIndex()) { + map[parameter] = this.actualTypeArguments[i] + } + this.rawType as Class<*> + } + is Class<*> -> { + this + } + else -> { + throw IllegalArgumentException() + } + } + + if (raw == target) { + var result = map[target.typeParameters[index]] + while (result is TypeVariable<*>) { + result = map[result] + } + return result + } + + return if (target.isInterface) { + raw.genericInterfaces.map { + it.getTypeArgument(target, index, map) + }.firstOrNull { it != null } ?: raw.genericSuperclass?.getTypeArgument(target, index, map) + } else { + raw.genericSuperclass?.getTypeArgument(target, index, map) + } +} + +fun Type.getTypeArgument(target: Class<*>, index: Int): Type { + return this.getTypeArgument(target, index, mutableMapOf()) ?: throw IllegalArgumentException() +} + +val Type.kotlinType: KType + get() { + return when (this) { + is Class<*> -> { + this.kotlin.createType() + } + is ParameterizedType -> { + (this.rawType as Class<*>).kotlin.createType( + this.actualTypeArguments.map { + it.toKTypeProjection() + } + ) + } + else -> throw UnsupportedOperationException("Unsupported '$this'.") + } + } + +fun Type.toKTypeProjection(): KTypeProjection { + return when (this) { + is Class<*> -> { + KTypeProjection.invariant(this.kotlinType) + } + is ParameterizedType -> { + KTypeProjection.invariant(this.kotlinType) + } + is WildcardType -> { + when { + this.lowerBounds.isNotEmpty() -> KTypeProjection.contravariant(this.lowerBounds[0].kotlinType) + this.upperBounds.isNotEmpty() -> KTypeProjection.covariant(this.upperBounds[0].kotlinType) + else -> KTypeProjection.STAR + } + } + else -> throw UnsupportedOperationException("Unsupported '$this'.") + } +} + +val Type.jvm: JvmType + get() { + return JvmType.fromType(this) + } + +val Class<*>.jvm: SimpleType + get() { + return JvmType.fromType(this) as SimpleType + } + +fun String.toType(): JvmType { + return JvmType.fromName(this) +} + +/** + * Tool function for suppressing unchecked cast message with 'v as T' in many generic functions. + */ +@Suppress("UNCHECKED_CAST", "NOTHING_TO_INLINE") +inline fun Any?.uncheckedCast(): T { + return this as T +} + +/** + * Create or get instance for [KClass], it will find no-arg constructor for create new instance, or return instance for + * kotlin object, or calling static no-arg function 'provider' to create new instance. + */ +fun KClass.instance(): T { + this.constructors.singleOrNull { it.parameters.all(KParameter::isOptional) }?.call()?.let { return it } + this.objectInstance?.let { return it } + this.java.methods.first { + it.name == "provider" && Modifier.isStatic(it.modifiers) && Modifier.isPublic(it.modifiers) && it.parameterCount == 0 + }.invoke(null)?.let { return it.uncheckedCast() } + throw IllegalArgumentException("Class should have a single no-arg constructor, or be a 'object', or has static no-arg 'provider' function: $this") +} + +fun Class.instance(): T { + return this.kotlin.instance() +} + +val KClass<*>.allProperties: List> + get() { + return this.memberProperties + this.superclasses.flatMap { it.allProperties } + } + +object Reflect { + fun getPrivateField(any: Any, name: String): T? { + return getPrivateField(any.javaClass, any, name) + } + + fun getPrivateField(clazz: Class<*>, any: Any, name: String): T? { + return try { + clazz.getDeclaredField(name).apply { isAccessible = true }.get(any).uncheckedCast() + } catch (e: NoSuchFieldException) { + if (clazz == Any::class.java) { + throw e + } + getPrivateField(clazz.superclass, any, name) + } + } + + fun setPrivateField(any: Any, name: String, value: T?) { + setPrivateField(any.javaClass, any, name, value) + } + + fun setPrivateField(clazz: Class<*>, any: Any, name: String, value: T?) { + try { + clazz.getDeclaredField(name).apply { isAccessible = true }.set(any, value).uncheckedCast() + } catch (e: NoSuchFieldException) { + if (clazz == Any::class.java) { + throw e + } + setPrivateField(clazz.superclass, any, name, value) + } + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/SimpleType.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/SimpleType.kt new file mode 100644 index 00000000..2556816f --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/SimpleType.kt @@ -0,0 +1,27 @@ +package com.bybutter.sisyphus.reflect + +import java.util.concurrent.ConcurrentHashMap + +open class SimpleType protected constructor(val raw: Class<*>) : JvmType() { + companion object { + private val cache = ConcurrentHashMap, SimpleType>() + + operator fun invoke(raw: Class<*>): SimpleType { + return cache.getOrPut(raw) { + SimpleType(raw) + } + } + } + + override fun equals(other: Any?): Boolean { + return raw == (other as? SimpleType)?.raw + } + + override fun hashCode(): Int { + return raw.hashCode() + } + + override fun toString(): String { + return raw.typeName + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/TypeReference.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/TypeReference.kt new file mode 100644 index 00000000..677512c9 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/reflect/TypeReference.kt @@ -0,0 +1,32 @@ +package com.bybutter.sisyphus.reflect + +import java.lang.reflect.Type +import kotlin.reflect.KClass + +/** + * Type reference for resolve java generic types. + */ +abstract class TypeReference protected constructor() { + companion object { + private val typeCache = mutableMapOf, Type>() + + inline operator fun invoke(): TypeReference { + return object : TypeReference() {} + } + } + + /** + * Get the type of current reference object. + */ + val type by lazy { + typeCache.getOrPut(this.javaClass.kotlin) { + this.javaClass.getTypeArgument(TypeReference::class.java, 0) + } + } +} + +/** + * Helper function to get type reference. + */ +inline val T.typeReference: TypeReference + get() = TypeReference() diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/AES.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/AES.kt new file mode 100644 index 00000000..e1c14455 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/AES.kt @@ -0,0 +1,33 @@ +package com.bybutter.sisyphus.security + +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +fun ByteArray.aesEncrypt(key: String, from: Int = 0, to: Int = this.size): ByteArray { + val keyData = key.toByteArray().sha256() + val ivData = keyData.sha256() + return this.aesEncrypt(keyData, ivData, from, to) +} + +fun ByteArray.aesEncrypt(key: ByteArray, iv: ByteArray, from: Int = 0, to: Int = this.size): ByteArray { + val ivSpec = IvParameterSpec(iv) + val keySpec = SecretKeySpec(key, "AES") + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec) + return cipher.doFinal(this, from, to - from) +} + +fun ByteArray.aesDecrypt(key: String, from: Int = 0, to: Int = this.size): ByteArray { + val keyData = key.toByteArray().sha256() + val ivData = keyData.sha256() + return this.aesDecrypt(keyData, ivData, from, to) +} + +fun ByteArray.aesDecrypt(key: ByteArray, iv: ByteArray, from: Int = 0, to: Int = this.size): ByteArray { + val ivSpec = IvParameterSpec(iv) + val keySpec = SecretKeySpec(key, "AES") + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec) + return cipher.doFinal(this, from, to - from) +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/Base64.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/Base64.kt new file mode 100644 index 00000000..5a60edfd --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/Base64.kt @@ -0,0 +1,74 @@ +package com.bybutter.sisyphus.security + +import java.nio.charset.Charset +import java.util.Base64 + +/** + * Encode a string with base64 encoding without padding. + */ +fun String.base64(charset: Charset = Charsets.UTF_8): String { + return this.toByteArray(charset).base64() +} + +/** + * Encode a string with url safe base64 encoding without padding. + */ +fun String.base64UrlSafe(charset: Charset = Charsets.UTF_8): String { + return this.toByteArray(charset).base64UrlSafe() +} + +/** + * Encode a string with base64 encoding with padding. + */ +fun String.base64WithPadding(charset: Charset = Charsets.UTF_8): String { + return this.toByteArray(charset).base64WithPadding() +} + +/** + * Encode a string with url safe base64 encoding with padding. + */ +fun String.base64UrlSafeWithPadding(charset: Charset = Charsets.UTF_8): String { + return this.toByteArray(charset).base64UrlSafeWithPadding() +} + +/** + * Encode data with base64 encoding without padding. + */ +fun ByteArray.base64(): String { + return Base64.getEncoder().withoutPadding().encodeToString(this) +} + +/** + * Encode data with url safe base64 encoding without padding. + */ +fun ByteArray.base64UrlSafe(): String { + return Base64.getUrlEncoder().withoutPadding().encodeToString(this) +} + +/** + * Encode data with base64 encoding with padding. + */ +fun ByteArray.base64WithPadding(): String { + return Base64.getEncoder().encodeToString(this) +} + +/** + * Encode data with url safe base64 encoding with padding. + */ +fun ByteArray.base64UrlSafeWithPadding(): String { + return Base64.getUrlEncoder().encodeToString(this).replace('=', '~') +} + +/** + * Decode base64 encoded data. + */ +fun String.base64Decode(): ByteArray { + return Base64.getDecoder().decode(this) +} + +/** + * Decode url safe base64 encoded data. + */ +fun String.base64UrlSafeDecode(): ByteArray { + return Base64.getUrlDecoder().decode(this.replace('~', '=')) +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/CRC32.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/CRC32.kt new file mode 100644 index 00000000..608a4015 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/CRC32.kt @@ -0,0 +1,21 @@ +package com.bybutter.sisyphus.security + +import java.util.zip.CRC32 + +/** + * Calculate CRC32 of string. + */ +fun String.crc32(): Long { + val checksum = CRC32() + checksum.update(this.toByteArray()) + return checksum.value +} + +/** + * Calculate CRC32 of data. + */ +fun ByteArray.crc32(): Long { + val checksum = CRC32() + checksum.update(this) + return checksum.value +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/MAC.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/MAC.kt new file mode 100644 index 00000000..1f741ef0 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/MAC.kt @@ -0,0 +1,12 @@ +package com.bybutter.sisyphus.security + +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +fun ByteArray.hmacSha1Encrypt(password: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA1").apply { + init(SecretKeySpec(password, "HmacSHA1")) + } + + return mac.doFinal(this) +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/Md5.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/Md5.kt new file mode 100644 index 00000000..88b30514 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/Md5.kt @@ -0,0 +1,25 @@ +package com.bybutter.sisyphus.security + +import com.bybutter.sisyphus.data.hex +import java.security.MessageDigest + +/** + * Calculate md5 of string, and convert it to hex string. + */ +fun String.md5(): String { + return this.md5Data().hex() +} + +/** + * Calculate md5 of string. + */ +fun String.md5Data(): ByteArray { + return MessageDigest.getInstance("MD5").digest(this.toByteArray()) +} + +/** + * Calculate md5 of data. + */ +fun ByteArray.md5(): ByteArray { + return MessageDigest.getInstance("MD5").digest(this) +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/SHA1.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/SHA1.kt new file mode 100644 index 00000000..525f8282 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/SHA1.kt @@ -0,0 +1,25 @@ +package com.bybutter.sisyphus.security + +import com.bybutter.sisyphus.data.hex +import java.security.MessageDigest + +/** + * Calculate SHA-1 of string, and convert it to hex string. + */ +fun String.sha1(): String { + return this.sha1Data().hex() +} + +/** + * Calculate SHA-1 of string. + */ +fun String.sha1Data(): ByteArray { + return MessageDigest.getInstance("SHA-1").digest(this.toByteArray()) +} + +/** + * Calculate SHA-1 of data. + */ +fun ByteArray.sha1(): ByteArray { + return MessageDigest.getInstance("SHA-1").digest(this) +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/SHA256.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/SHA256.kt new file mode 100644 index 00000000..55544536 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/security/SHA256.kt @@ -0,0 +1,25 @@ +package com.bybutter.sisyphus.security + +import com.bybutter.sisyphus.data.hex +import java.security.MessageDigest + +/** + * Calculate SHA-256 of string, and convert it to hex string. + */ +fun String.sha256(): String { + return this.sha1Data().hex() +} + +/** + * Calculate SHA-256 of string. + */ +fun String.sha256Data(): ByteArray { + return MessageDigest.getInstance("SHA-256").digest(this.toByteArray()) +} + +/** + * Calculate SHA-256 of data. + */ +fun ByteArray.sha256(): ByteArray { + return MessageDigest.getInstance("SHA-256").digest(this) +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/spi/Ordered.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/spi/Ordered.kt new file mode 100644 index 00000000..356beccf --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/spi/Ordered.kt @@ -0,0 +1,9 @@ +package com.bybutter.sisyphus.spi + +interface Ordered : Comparable { + val order: Int + + override fun compareTo(other: Ordered): Int { + return order.compareTo(other.order) + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/spi/ServiceLoader.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/spi/ServiceLoader.kt new file mode 100644 index 00000000..d5bcbf5a --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/spi/ServiceLoader.kt @@ -0,0 +1,42 @@ +package com.bybutter.sisyphus.spi + +import com.bybutter.sisyphus.reflect.instance +import com.bybutter.sisyphus.reflect.uncheckedCast + +/** + * Java SPI(Service Provider Interface) like [ServiceLoader], + * but more lightweight and support provider and Kotlin Object. + */ +object ServiceLoader { + /** + * Load services which provided specified interface. + */ + fun load(clazz: Class): List { + return load(clazz, clazz.classLoader) + } + + /** + * Load services which provided specified interface with [ClassLoader]. + */ + fun load(clazz: Class, loader: ClassLoader): List { + val result = loader.getResources("META-INF/services/${clazz.name}") + .asSequence().flatMap { + it.openStream().use { + it.reader().readLines().asSequence() + } + }.map { + it.trim() + }.filter { + it.isNotBlank() + }.map { + Class.forName(it).instance().uncheckedCast() + }.toMutableList() + + if (Ordered::class.java.isAssignableFrom(clazz)) { + result.sortBy { + it as Ordered + } + } + return result + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/spring/BeanUtils.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/spring/BeanUtils.kt new file mode 100644 index 00000000..9a2ae884 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/spring/BeanUtils.kt @@ -0,0 +1,137 @@ +package com.bybutter.sisyphus.spring + +import org.springframework.beans.factory.ListableBeanFactory +import org.springframework.beans.factory.config.BeanDefinition +import org.springframework.beans.factory.config.ConfigurableBeanFactory +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.ConfigurationClassPostProcessor +import org.springframework.core.Conventions +import org.springframework.core.Ordered +import org.springframework.core.annotation.AnnotationUtils + +/** + * BeanUtils for spring framework. + */ +object BeanUtils { + + inline fun getBeans(beanFactory: ListableBeanFactory): Map { + return getBeans(beanFactory, T::class.java) + } + + /** + * Get beans of specified type, if [beanFactory] is [ConfigurableBeanFactory] or [ApplicationContext] this function will return sorted beans. + */ + fun getBeans(beanFactory: ListableBeanFactory, type: Class): Map { + val factory = when (beanFactory) { + is ApplicationContext -> beanFactory.autowireCapableBeanFactory + else -> beanFactory + } + + if (factory is ConfigurableListableBeanFactory) { + return getSortedBeans(factory, type) + } + + return getBeansWithCondition(beanFactory, type) { _, _ -> + true + } + } + + fun getSortedBeans(beanFactory: ConfigurableListableBeanFactory, type: Class): Map { + return getSortedBeansWithCondition(beanFactory, type) { _, _, _ -> + true + } + } + + inline fun getBeansWithAnnotation(beanFactory: ListableBeanFactory, annotation: Class): Map { + return getBeansWithAnnotation(beanFactory, T::class.java, annotation) + } + + /** + * Get beans of specified type and annotation, if [beanFactory] is [ConfigurableBeanFactory] or [ApplicationContext] this function will return sorted beans. + */ + fun getBeansWithAnnotation(beanFactory: ListableBeanFactory, type: Class, annotation: Class): Map { + val factory = when (beanFactory) { + is ApplicationContext -> beanFactory.autowireCapableBeanFactory + else -> beanFactory + } + + if (factory is ConfigurableListableBeanFactory) { + return getSortedBeansWithAnnotation(factory, type, annotation) + } + + return getBeansWithCondition(beanFactory, type) { name, f -> + val beanType = f.getType(name) ?: return@getBeansWithCondition false + AnnotationUtils.getAnnotation(beanType, annotation) != null + } + } + + fun getSortedBeansWithAnnotation(beanFactory: ConfigurableListableBeanFactory, type: Class, annotation: Class): Map { + return getSortedBeansWithCondition(beanFactory, type) { _, _, definition -> + val beanType = Class.forName(definition.beanClassName) + AnnotationUtils.getAnnotation(beanType, annotation) != null + } + } + + /** + * Get beans of specified type and annotation, if [beanFactory] is [ConfigurableBeanFactory] or [ApplicationContext] this function will return sorted beans. + */ + fun getBeansWithCondition(beanFactory: ListableBeanFactory, type: Class, condition: (String, ListableBeanFactory) -> Boolean): Map { + val factory = when (beanFactory) { + is ApplicationContext -> beanFactory.autowireCapableBeanFactory + else -> beanFactory + } + + if (factory is ConfigurableListableBeanFactory) { + return getSortedBeansWithCondition(factory, type) { name, _, _ -> + condition(name, beanFactory) + } + } + + val names = beanFactory.getBeanNamesForType(type) + val result = mutableMapOf() + + for (name in names) { + if (condition(name, beanFactory)) { + result[name] = beanFactory.getBean(name, type) + } + } + + return result + } + + fun getSortedBeansWithCondition(beanFactory: ConfigurableListableBeanFactory, type: Class, condition: (String, ConfigurableBeanFactory, BeanDefinition) -> Boolean): Map { + val names = beanFactory.getBeanNamesForType(type) + val beans = mutableMapOf() + + for (name in names) { + val beanDefinition = beanFactory.getMergedBeanDefinition(name) + if (condition(name, beanFactory, beanDefinition)) { + beans[name] = beanDefinition + } + } + + return beans.toSortedMap(BeanDefinitionOrderComparer(beans)).mapValues { + @Suppress("UNCHECKED_CAST") + beanFactory.getBean(it.key) as T + } + } +} + +private class BeanDefinitionOrderComparer(private val map: Map) : Comparator { + companion object { + private val ORDER_ATTRIBUTE = Conventions.getQualifiedAttributeName(ConfigurationClassPostProcessor::class.java, "order") + } + + override fun compare(o1: String, o2: String): Int { + val order = getOrder(o1).compareTo(getOrder(o2)) + if (order != 0) return order + return o1.compareTo(o2) + } + + private fun getOrder(beanName: String): Int { + val bean = map[beanName] ?: return Ordered.LOWEST_PRECEDENCE + val order = bean.getAttribute(ORDER_ATTRIBUTE) as? Int + return order ?: Ordered.LOWEST_PRECEDENCE + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Case.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Case.kt new file mode 100644 index 00000000..417eefb8 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Case.kt @@ -0,0 +1,54 @@ +package com.bybutter.sisyphus.string + +import com.bybutter.sisyphus.string.case.CamelCaseFormatter +import com.bybutter.sisyphus.string.case.CaseFormat +import com.bybutter.sisyphus.string.case.CaseFormatter +import com.bybutter.sisyphus.string.case.CommonWordSplitter +import com.bybutter.sisyphus.string.case.DotCaseFormatter +import com.bybutter.sisyphus.string.case.KebabCaseFormatter +import com.bybutter.sisyphus.string.case.PascalCaseFormatter +import com.bybutter.sisyphus.string.case.ScreamingSnakeCaseFormatter +import com.bybutter.sisyphus.string.case.SnakeCaseFormatter +import com.bybutter.sisyphus.string.case.SpaceCaseFormatter +import com.bybutter.sisyphus.string.case.TitleCaseFormatter +import com.bybutter.sisyphus.string.case.TrainCaseFormatter +import com.bybutter.sisyphus.string.case.UpperDotCaseFormatter +import com.bybutter.sisyphus.string.case.UpperSpaceCaseFormatter +import com.bybutter.sisyphus.string.case.WordSplitter + +fun String.toCase(format: CaseFormat) = format.format(this) + +fun String.toCase(formatter: CaseFormatter, splitter: WordSplitter = CommonWordSplitter) = formatter.format(splitter.split(this)) + +/** Converts a string to 'SCREAMING_SNAKE_CASE'. */ +fun String.toScreamingSnakeCase() = toCase(ScreamingSnakeCaseFormatter) + +/** Converts a string to 'snake_case.' */ +fun String.toSnakeCase() = toCase(SnakeCaseFormatter) + +/** Converts a string to 'PascalCase'. */ +fun String.toPascalCase() = toCase(PascalCaseFormatter) + +/** Converts a string to 'camelCase'. */ +fun String.toCamelCase() = toCase(CamelCaseFormatter) + +/** Converts a string to 'TRAIN-CASE'. */ +fun String.toTrainCase() = toCase(TrainCaseFormatter) + +/** Converts a string to 'kebab-case'. */ +fun String.toKebabCase() = toCase(KebabCaseFormatter) + +/** Converts a string to 'UPPER SPACE CASE'. */ +fun String.toUpperSpaceCase() = toCase(UpperSpaceCaseFormatter) + +/** Converts a string to 'Title Case'. */ +fun String.toTitleCase() = toCase(TitleCaseFormatter) + +/** Converts a string to 'lower space case'. */ +fun String.toLowerSpaceCase() = toCase(SpaceCaseFormatter) + +/** Converts a string to 'UPPER.DOT.CASE'. */ +fun String.toUpperDotCase() = toCase(UpperDotCaseFormatter) + +/** Converts a string to 'dot.case'. */ +fun String.toDotCase() = toCase(DotCaseFormatter) diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Escape.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Escape.kt new file mode 100644 index 00000000..687de550 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Escape.kt @@ -0,0 +1,83 @@ +package com.bybutter.sisyphus.string + +fun String.escape(): String = buildString { + val data = this@escape + var i = 0 + + while (i < data.length) { + when (val ch = data[i]) { + '\\' -> append("\\\\") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + '\'' -> append("\\'") + '\"' -> append("\\\"") + else -> { + if (ch.isISOControl()) { + if (ch.toInt() < 255) { + append("\\") + append(ch.toInt().toString(8)) + } else { + append("\\u") + val hex = ch.toInt().toString(16) + repeat(4 - hex.length) { + append('0') + } + append(hex) + } + } else { + append(ch) + } + } + } + i++ + } +} + +fun String.unescape(): String = buildString(this.length) { + val data = this@unescape + var i = 0 + + while (i < data.length) { + var ch = data[i] + if (ch != '\\') { + append(ch) + i++ + continue + } + + i++ + ch = data[i] + + when (ch) { + '\\' -> append('\\') + 'b' -> append('\b') + 'f' -> append('\u000C') + 'n' -> append('\n') + 'r' -> append('\r') + 't' -> append('\t') + '\"' -> append('\"') + '\'' -> append('\'') + 'u' -> { + append("${data[i + 1]}${data[i + 2]}${data[i + 3]}${data[i + 4]}".toInt(16).toChar()) + i += 4 + } + in '0'..'7' -> { + var octal = "" + while ((octal.length < 2 || (octal.length == 2 && octal[0] in '0'..'3')) && data[i] in '0'..'7') { + octal += data[i] + i++ + } + append(octal.toInt(8).toChar()) + i-- + } + else -> { + append('\\') + append(ch) + } + } + i++ + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Extensions.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Extensions.kt new file mode 100644 index 00000000..83519095 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Extensions.kt @@ -0,0 +1,23 @@ +package com.bybutter.sisyphus.string + +/** + * Returns a character at the given [index] or `0` if the [index] is out of bounds of this char sequence. + */ +@Suppress("ConvertTwoComparisonsToRangeCheck") +fun CharSequence.getOrZero(index: Int): Char { + return if (index >= 0 && index <= lastIndex) get(index) else 0.toChar() +} + +fun String.leftPadding(size: Int, char: Char = ' '): String { + if (this.length < size) { + return "${char.toString().repeat(size - this.length)}$this" + } + return this +} + +fun String.rightPadding(size: Int, char: Char = ' '): String { + if (this.length < size) { + return "$this${char.toString().repeat(size - this.length)}" + } + return this +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/PathMatcher.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/PathMatcher.kt new file mode 100644 index 00000000..9c9904ba --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/PathMatcher.kt @@ -0,0 +1,118 @@ +package com.bybutter.sisyphus.string + +object PathMatcher { + fun CharSequence.matches(pattern: CharSequence, pathDelimiters: Set = setOf('/')): Boolean { + return match(pattern, this, pathDelimiters) + } + + fun match(pattern: CharSequence, path: CharSequence, pathDelimiters: Set = setOf('/')): Boolean { + return normalMatch(pattern, 0, path, 0, pathDelimiters) + } + + private fun normalMatch(pat: CharSequence, p: Int, str: CharSequence, s: Int, pathDelimiters: Set): Boolean { + var pi = p + var si = s + while (pi < pat.length) { + val pc = pat[pi] + val sc = str.getOrZero(si) + // Got * in pattern, enter the wildcard mode. + // ↓ ↓ + // pattern: a/* a/* + // ↓ ↓ + // string: a/bcd a/ + if (pc == '*') { + pi++ + // Got * in pattern again, enter the multi-wildcard mode. + // ↓ ↓ + // pattern: a/** a/** + // ↓ ↓ + // string: a/bcd a/ + return if (pat.getOrZero(pi) == '*') { + pi++ + // Enter the multi-wildcard mode. + // ↓ ↓ + // pattern: a/** a/** + // ↓ ↓ + // string: a/bcd a/ + multiWildcardMatch(pat, pi, str, si, pathDelimiters) + } else { // Enter the wildcard mode. + // ↓ + // pattern: a/* + // ↓ + // string: a/bcd + wildcardMatch(pat, pi, str, si, pathDelimiters) + } + } + // Matching ? for non-'/' char, or matching the same chars. + // ↓ ↓ ↓ + // pattern: a/?/c a/b/c a/b + // ↓ ↓ ↓ + // string: a/b/c a/b/d a/d + if (pc == '?' && sc.toInt() != 0 && sc !in pathDelimiters || pc == sc) { + si++ + pi++ + continue + } + // Not matched. + // ↓ + // pattern: a/b + // ↓ + // string: a/c + return false + } + return si == str.length + } + + private fun wildcardMatch(pat: CharSequence, p: Int, str: CharSequence, s: Int, pathDelimiters: Set): Boolean { + var si = s + val pc = pat.getOrZero(p) + while (true) { + val sc = str.getOrZero(si) + if (sc in pathDelimiters) { + // Both of pattern and string '/' matched, exit wildcard mode. + // ↓ + // pattern: a/*/ + // ↓ + // string: a/bc/ + return if (pc == sc) { + normalMatch(pat, p + 1, str, si + 1, pathDelimiters) + } else false + // Not matched string in current path part. + // ↓ ↓ + // pattern: a/* a/*d + // ↓ ↓ + // string: a/bc/ a/bc/ + } + // Try to enter normal mode, if not matched, increasing pointer of string and try again. + if (!normalMatch(pat, p, str, si, pathDelimiters)) { // End of string, not matched. + if (si >= str.length) { + return false + } + si++ + continue + } + // Matched in next normal mode. + return true + } + } + + private fun multiWildcardMatch(pat: CharSequence, p: Int, str: CharSequence, s: Int, pathDelimiters: Set): Boolean { + // End of pattern, just check the end of string is '/' quickly. + var si = s + if (p >= pat.length && si < str.length) { + return str[str.length - 1] !in pathDelimiters + } + while (true) { + // Try to enter normal mode, if not matched, increasing pointer of string and try again. + if (!normalMatch(pat, p, str, si, pathDelimiters)) { + // End of string, not matched. + if (si >= str.length) { + return false + } + si++ + continue + } + return true + } + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Pluralize.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Pluralize.kt new file mode 100644 index 00000000..5d87fb07 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Pluralize.kt @@ -0,0 +1,49 @@ +package com.bybutter.sisyphus.string + +import com.bybutter.sisyphus.string.case.CaseFormat +import com.bybutter.sisyphus.string.case.CommonWordSplitter +import com.bybutter.sisyphus.string.pluralize.PluralizeUtil + +val String.isSingular: Boolean + get() { + val words = this.splitWords().toMutableList() + if (words.isEmpty()) return false + return PluralizeUtil.isSingular(words.last()) + } + +val String.isPlural: Boolean + get() { + val words = this.splitWords().toMutableList() + if (words.isEmpty()) return false + return PluralizeUtil.isPlural(words.last()) + } + +fun String.pluralize(count: Int): String { + return if (count == 1) { + singular() + } else { + plural() + } +} + +fun String.plural(): String { + val case = CaseFormat.bestGuess(this) + val words = this.splitWords().toMutableList() + if (words.isEmpty()) return this + + words[words.lastIndex] = PluralizeUtil.plural(words.last()) + return case.formatter.format(words) +} + +fun String.singular(): String { + val case = CaseFormat.bestGuess(this) + val words = this.splitWords().toMutableList() + if (words.isEmpty()) return this + + words[words.lastIndex] = PluralizeUtil.singular(words.last()) + return case.formatter.format(words) +} + +private fun String.splitWords(): List { + return CommonWordSplitter.split(this) +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Random.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Random.kt new file mode 100644 index 00000000..3f5539ea --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Random.kt @@ -0,0 +1,53 @@ +package com.bybutter.sisyphus.string + +import kotlin.random.Random + +private const val base62Table = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +fun randomString(length: Int): String { + return randomString(length, base62Table) +} + +fun Random.randomString(length: Int): String { + return Random.randomString(length, base62Table) +} + +private const val numberTable = "0123456789" + +fun randomNumberString(length: Int): String { + return randomString(length, numberTable) +} + +fun Random.randomNumberString(length: Int): String { + return Random.randomString(length, numberTable) +} + +private const val letterTable = "abcdefghijklmnopqrstuvwxyz" + +fun randomLetterString(length: Int): String { + return randomString(length, letterTable) +} + +fun Random.randomLetterString(length: Int): String { + return Random.randomString(length, letterTable) +} + +private const val friendlyTable = "123456789abcdefghjkmnpqrstuvwxyz" + +fun randomFriendlyString(length: Int): String { + return randomString(length, friendlyTable) +} + +fun Random.randomFriendlyString(length: Int): String { + return Random.randomString(length, friendlyTable) +} + +fun randomString(length: Int, table: CharSequence): String = buildString { + return Random.randomString(length, table) +} + +fun Random.randomString(length: Int, table: CharSequence): String = buildString { + repeat(length) { + append(table.random(this@randomString)) + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Version.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Version.kt new file mode 100644 index 00000000..0f5504f0 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/Version.kt @@ -0,0 +1,55 @@ +package com.bybutter.sisyphus.string + +data class Version(val major: Int, val minor: Int, val patch: Int) : Comparable { + constructor(major: Int, minor: Int) : this(major, minor, 0) + + private val version = versionOf(major, minor, patch) + + private fun versionOf(major: Int, minor: Int, patch: Int): Int { + return major.shl(16) + minor.shl(8) + patch + } + + /** + * Returns the string representation of this version + */ + override fun toString(): String = "$major.$minor.$patch" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + val otherVersion = (other as? Version) ?: return false + return this.version == otherVersion.version + } + + override fun hashCode(): Int = version + + override fun compareTo(other: Version): Int = version - other.version + + /** + * Returns `true` if this version is not less than the version specified + * with the provided [major] and [minor] components. + */ + fun isAtLeast(major: Int, minor: Int): Boolean = + this.major > major || (this.major == major && + this.minor >= minor) + + /** + * Returns `true` if this version is not less than the version specified + * with the provided [major], [minor] and [patch] components. + */ + fun isAtLeast(major: Int, minor: Int, patch: Int): Boolean = + this.major > major || (this.major == major && + (this.minor > minor || this.minor == minor && + this.patch >= patch)) + + companion object { + fun parse(version: String): Version { + val parts = version.split('.').map { it.toInt() } + + return Version( + parts.getOrElse(0) { 0 }, + parts.getOrElse(1) { 0 }, + parts.getOrElse(2) { 0 } + ) + } + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/BaseCaseFormatter.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/BaseCaseFormatter.kt new file mode 100644 index 00000000..e0a2977a --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/BaseCaseFormatter.kt @@ -0,0 +1,21 @@ +package com.bybutter.sisyphus.string.case + +abstract class BaseCaseFormatter : CaseFormatter { + protected open fun formatWord(index: Int, word: CharSequence): CharSequence { + return word + } + + protected open fun appendDelimiter(builder: StringBuilder) { + } + + override fun format(words: Iterable): String { + return buildString { + for ((index, word) in words.withIndex()) { + if (isNotEmpty()) { + appendDelimiter(this) + } + append(formatWord(index, word)) + } + } + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/CamelCaseFormatter.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/CamelCaseFormatter.kt new file mode 100644 index 00000000..52e901c1 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/CamelCaseFormatter.kt @@ -0,0 +1,14 @@ +package com.bybutter.sisyphus.string.case + +object CamelCaseFormatter : BaseCaseFormatter() { + override fun formatWord(index: Int, word: CharSequence): CharSequence { + return buildString { + if (index == 0) { + return word.toString().toLowerCase() + } else { + append(word.first().toUpperCase()) + append(word.substring(1).toLowerCase()) + } + } + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/CaseFormat.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/CaseFormat.kt new file mode 100644 index 00000000..a814dd52 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/CaseFormat.kt @@ -0,0 +1,33 @@ +package com.bybutter.sisyphus.string.case + +enum class CaseFormat(val formatter: CaseFormatter, val splitter: WordSplitter = CommonWordSplitter) { + SCREAMING_SNAKE_CASE(ScreamingSnakeCaseFormatter), + SNAKE_CASE(SnakeCaseFormatter), + PASCAL_CASE(PascalCaseFormatter), + CAMEL_CASE(CamelCaseFormatter), + TRAIN_CASE(TrainCaseFormatter), + KEBAB_CASE(KebabCaseFormatter), + UPPER_SPACE_CASE(UpperSpaceCaseFormatter), + SPACE_CASE(SpaceCaseFormatter), + TITLE_CASE_CASE(TitleCaseFormatter), + UPPER_DOT_CASE(UpperDotCaseFormatter), + DOT_CASE(DotCaseFormatter); + + fun format(words: String): String { + return formatter.format(splitter.split(words)) + } + + companion object { + fun bestGuess(string: String): CaseFormat { + if (string.isBlank()) return CAMEL_CASE + val words = CommonWordSplitter.split(string) + + for (value in values()) { + val formatted = value.formatter.format(words) + if (formatted == string) return value + } + + return CAMEL_CASE + } + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/CaseFormatter.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/CaseFormatter.kt new file mode 100644 index 00000000..71c9d65c --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/CaseFormatter.kt @@ -0,0 +1,5 @@ +package com.bybutter.sisyphus.string.case + +interface CaseFormatter { + fun format(words: Iterable): String +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/CommonWordSplitter.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/CommonWordSplitter.kt new file mode 100644 index 00000000..8fa38ae0 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/CommonWordSplitter.kt @@ -0,0 +1,204 @@ +package com.bybutter.sisyphus.string.case + +import com.bybutter.sisyphus.string.getOrZero + +object CommonWordSplitter : WordSplitter { + override fun split(string: CharSequence): List { + return entryPoint(string) + } + + private fun entryPoint(string: CharSequence, pos: Int = 0, stack: StringBuilder = StringBuilder(), result: MutableList = mutableListOf()): List { + result.append(stack) + + if (pos >= string.length) { + return result + } + val ch = string[pos] + return when { + ch.isDigit() -> { + handleDigital(string, pos, stack, result) + } + ch.isLowerCase() -> { + handleLowerCase(string, pos, stack, result) + } + ch.isUpperCase() -> { + handleUpperCase(string, pos, stack, result) + } + ch.isDelimiter() -> { + handleDelimiter(string, pos, stack, result) + } + else -> { + handleUnknown(string, pos, stack, result) + } + } + } + + private fun handleDigital(string: CharSequence, pos: Int, stack: StringBuilder, result: MutableList): List { + var index = pos + val digital = StringBuilder() + digital.append(string[index++]) + + while (true) { + val ch = string.getOrZero(index) + when { + ch.isDigit() -> { + digital.append(ch) + index++ + } + ch.isUpperCase() -> { + digital.append(ch) + index++ + return handleUpperCaseAfterDigital(string, index, stack, digital, result) + } + ch.isLowerCase() -> { + digital.append(ch) + index++ + + val lastCh = stack.getOrZero(stack.lastIndex) + return if (lastCh.isUpperCase()) { + result.append(stack) + handleLowerCase(string, index, digital, result) + } else { + stack.append(digital) + handleLowerCase(string, index, stack, result) + } + } + else -> { + stack.append(digital) + return entryPoint(string, index, stack, result) + } + } + } + } + + private fun handleLowerCase(string: CharSequence, pos: Int, stack: StringBuilder, result: MutableList): List { + var index = pos + + while (true) { + val ch = string.getOrZero(index) + when { + ch.isLowerCase() -> { + stack.append(ch) + index++ + } + ch.isDigit() -> { + return handleDigital(string, index, stack, result) + } + else -> { + return entryPoint(string, index, stack, result) + } + } + } + } + + private fun handleUpperCase(string: CharSequence, pos: Int, stack: StringBuilder, result: MutableList): List { + var index = pos + + while (true) { + val ch = string.getOrZero(index) + when { + ch.isUpperCase() -> { + stack.append(ch) + index++ + } + ch.isLowerCase() -> { + val last = stack.last() + stack.deleteCharAt(stack.length - 1) + result.append(stack) + stack.append(last) + return handleLowerCase(string, index, stack, result) + } + ch.isDigit() -> { + return handleDigital(string, index, stack, result) + } + else -> { + return entryPoint(string, index, stack, result) + } + } + } + } + + private fun handleUpperCaseAfterDigital(string: CharSequence, pos: Int, stack: StringBuilder, digital: StringBuilder, result: MutableList): List { + var index = pos + + while (true) { + val ch = string.getOrZero(index) + when { + ch.isUpperCase() -> { + digital.append(ch) + index++ + } + ch.isLowerCase() -> { + val last = digital.last() + digital.deleteCharAt(digital.length - 1) + + if (digital.last().isDigit()) { + stack.append(digital) + result.append(stack) + } else { + result.append(stack) + result.append(digital) + } + + stack.append(last) + return handleLowerCase(string, index, stack, result) + } + else -> { + if (!digital.last().isDigit()) { + result.append(stack) + } + stack.append(digital) + return entryPoint(string, index, stack, result) + } + } + } + } + + private fun handleUnknown(string: CharSequence, pos: Int, stack: StringBuilder, result: MutableList): List { + var index = pos + + while (true) { + val ch = string.getOrZero(index) + when { + ch.isUpperCase() || ch.isLowerCase() || ch.isDigit() || ch.isDelimiter() -> { + return entryPoint(string, index, stack, result) + } + else -> { + stack.append(ch) + index++ + } + } + } + } + + private fun handleDelimiter(string: CharSequence, pos: Int, stack: StringBuilder, result: MutableList): List { + var index = pos + 1 + + while (true) { + val ch = string.getOrZero(index) + when { + ch.isDelimiter() -> { + index++ + } + else -> { + return entryPoint(string, index, stack, result) + } + } + } + } + + private fun MutableList.append(builder: StringBuilder) { + if (builder.isEmpty()) { + return + } + + add(builder.toString()) + builder.clear() + } + + private val delimiters = setOf(' ', '_', '-', '.') + + private fun Char.isDelimiter(): Boolean { + return delimiters.contains(this) + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/DotCaseFormatter.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/DotCaseFormatter.kt new file mode 100644 index 00000000..92f050ca --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/DotCaseFormatter.kt @@ -0,0 +1,11 @@ +package com.bybutter.sisyphus.string.case + +object DotCaseFormatter : BaseCaseFormatter() { + override fun formatWord(index: Int, word: CharSequence): CharSequence { + return word.toString().toLowerCase() + } + + override fun appendDelimiter(builder: StringBuilder) { + builder.append('.') + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/KebabCaseFormatter.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/KebabCaseFormatter.kt new file mode 100644 index 00000000..94dfb66d --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/KebabCaseFormatter.kt @@ -0,0 +1,11 @@ +package com.bybutter.sisyphus.string.case + +object KebabCaseFormatter : BaseCaseFormatter() { + override fun formatWord(index: Int, word: CharSequence): CharSequence { + return word.toString().toLowerCase() + } + + override fun appendDelimiter(builder: StringBuilder) { + builder.append('-') + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/PascalCaseFormatter.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/PascalCaseFormatter.kt new file mode 100644 index 00000000..cd5f8752 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/PascalCaseFormatter.kt @@ -0,0 +1,10 @@ +package com.bybutter.sisyphus.string.case + +object PascalCaseFormatter : BaseCaseFormatter() { + override fun formatWord(index: Int, word: CharSequence): CharSequence { + return buildString { + append(word.first().toUpperCase()) + append(word.substring(1).toLowerCase()) + } + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/ScreamingSnakeCaseFormatter.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/ScreamingSnakeCaseFormatter.kt new file mode 100644 index 00000000..13815889 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/ScreamingSnakeCaseFormatter.kt @@ -0,0 +1,11 @@ +package com.bybutter.sisyphus.string.case + +object ScreamingSnakeCaseFormatter : BaseCaseFormatter() { + override fun formatWord(index: Int, word: CharSequence): CharSequence { + return word.toString().toUpperCase() + } + + override fun appendDelimiter(builder: StringBuilder) { + builder.append('_') + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/SnakeCaseFormatter.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/SnakeCaseFormatter.kt new file mode 100644 index 00000000..24050554 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/SnakeCaseFormatter.kt @@ -0,0 +1,11 @@ +package com.bybutter.sisyphus.string.case + +object SnakeCaseFormatter : BaseCaseFormatter() { + override fun formatWord(index: Int, word: CharSequence): CharSequence { + return word.toString().toLowerCase() + } + + override fun appendDelimiter(builder: StringBuilder) { + builder.append('_') + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/SpaceCaseFormatter.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/SpaceCaseFormatter.kt new file mode 100644 index 00000000..8cdadd73 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/SpaceCaseFormatter.kt @@ -0,0 +1,11 @@ +package com.bybutter.sisyphus.string.case + +object SpaceCaseFormatter : BaseCaseFormatter() { + override fun formatWord(index: Int, word: CharSequence): CharSequence { + return word.toString().toLowerCase() + } + + override fun appendDelimiter(builder: StringBuilder) { + builder.append(' ') + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/TitleCaseFormatter.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/TitleCaseFormatter.kt new file mode 100644 index 00000000..1d5cabc9 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/TitleCaseFormatter.kt @@ -0,0 +1,29 @@ +package com.bybutter.sisyphus.string.case + +object TitleCaseFormatter : BaseCaseFormatter() { + private val lowerCaseWord: Set = hashSetOf( + "a", "an", "the", + + "and", "but", "or", + + "on", "in", "with", "at", "by", + "of", "from", "for", "to" + ) + + override fun formatWord(index: Int, word: CharSequence): CharSequence { + val lowerCase = word.toString().toLowerCase() + + return if (lowerCaseWord.contains(lowerCase)) { + lowerCase + } else { + buildString { + append(lowerCase.first().toUpperCase()) + append(lowerCase.substring(1)) + } + } + } + + override fun appendDelimiter(builder: StringBuilder) { + builder.append(' ') + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/TrainCaseFormatter.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/TrainCaseFormatter.kt new file mode 100644 index 00000000..cb9ee8d6 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/TrainCaseFormatter.kt @@ -0,0 +1,11 @@ +package com.bybutter.sisyphus.string.case + +object TrainCaseFormatter : BaseCaseFormatter() { + override fun formatWord(index: Int, word: CharSequence): CharSequence { + return word.toString().toUpperCase() + } + + override fun appendDelimiter(builder: StringBuilder) { + builder.append('-') + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/UpperDotCaseFormatter.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/UpperDotCaseFormatter.kt new file mode 100644 index 00000000..af50d387 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/UpperDotCaseFormatter.kt @@ -0,0 +1,11 @@ +package com.bybutter.sisyphus.string.case + +object UpperDotCaseFormatter : BaseCaseFormatter() { + override fun formatWord(index: Int, word: CharSequence): CharSequence { + return word.toString().toUpperCase() + } + + override fun appendDelimiter(builder: StringBuilder) { + builder.append('.') + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/UpperSpaceCaseFormatter.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/UpperSpaceCaseFormatter.kt new file mode 100644 index 00000000..e47e664e --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/UpperSpaceCaseFormatter.kt @@ -0,0 +1,11 @@ +package com.bybutter.sisyphus.string.case + +object UpperSpaceCaseFormatter : BaseCaseFormatter() { + override fun formatWord(index: Int, word: CharSequence): CharSequence { + return word.toString().toUpperCase() + } + + override fun appendDelimiter(builder: StringBuilder) { + builder.append(' ') + } +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/WordSplitter.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/WordSplitter.kt new file mode 100644 index 00000000..b5ed28ad --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/case/WordSplitter.kt @@ -0,0 +1,5 @@ +package com.bybutter.sisyphus.string.case + +interface WordSplitter { + fun split(string: CharSequence): List +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/pluralize/PluralizeUtil.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/pluralize/PluralizeUtil.kt new file mode 100644 index 00000000..1eed4e51 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/string/pluralize/PluralizeUtil.kt @@ -0,0 +1,338 @@ +package com.bybutter.sisyphus.string.pluralize + +import com.bybutter.sisyphus.string.toPascalCase + +/** + * Translate from JavaScript module 'https://github.com/blakeembrey/pluralize' + */ +object PluralizeUtil { + private val uncountableWords = hashSetOf() + private val uncountableRules = mutableListOf() + private val irregularSingles = hashMapOf() + private val irregularPlurals = hashMapOf() + private val pluralizationRules = mutableListOf() + private val singularizationRules = mutableListOf() + + fun addIrregularRule(single: String, plural: String) { + irregularSingles[single] = plural + irregularPlurals[plural] = single + } + + fun addPluralRule(rule: Regex, replace: String) { + pluralizationRules.add(Rule(rule, replace)) + } + + fun addSingularRule(rule: Regex, replace: String) { + singularizationRules.add(Rule(rule, replace)) + } + + fun addUncountableWord(word: String) { + uncountableWords.add(word) + } + + fun addUncountableRule(rule: Regex) { + uncountableRules.add(rule) + } + + fun singular(word: String): String { + return replaceWord(word, irregularPlurals, irregularSingles, singularizationRules) + } + + fun isSingular(word: String): Boolean { + return checkWord(word, irregularPlurals, irregularSingles, singularizationRules) + } + + fun plural(word: String): String { + return replaceWord(word, irregularSingles, irregularPlurals, pluralizationRules) + } + + fun isPlural(word: String): Boolean { + return checkWord(word, irregularSingles, irregularPlurals, pluralizationRules) + } + + private fun restoreCase(word: String, token: String): String { + if (word == token) return token + if (word.all { it.isLowerCase() }) return token.toLowerCase() + if (word.all { it.isUpperCase() }) return token.toUpperCase() + if (word[0].isUpperCase() && word.substring(1).all { it.isLowerCase() }) return token.toPascalCase() + return token + } + + private fun replaceWord(word: String, replaceMap: Map, keepMap: Map, rules: List): String { + if (word.isBlank()) return word + + val token = word.toLowerCase() + if (keepMap.containsKey(token)) { + return word + } + + replaceMap[token]?.let { + return restoreCase(word, it) + } + + if (uncountableWords.contains(token)) { + return word + } + + if (uncountableRules.any { it.matches(token) }) { + return word + } + + val sanitizedToken = sanitizeWord(token, rules) + if (sanitizedToken == token) { + return word + } + return restoreCase(word, sanitizedToken) + } + + private fun sanitizeWord(token: String, rules: List): String { + for (rule in rules.asReversed()) { + if (rule.regex.containsMatchIn(token)) { + return rule.regex.replace(token, rule.replace) + } + } + return token + } + + private fun checkWord(word: String, replaceMap: Map, keepMap: Map, rules: List): Boolean { + if (word.isBlank()) return false + + val token = word.toLowerCase() + if (keepMap.containsKey(token)) { + return true + } + if (replaceMap.containsKey(token)) { + return false + } + if (uncountableWords.contains(token)) { + return false + } + if (uncountableRules.any { it.matches(token) }) { + return false + } + + return sanitizeWord(token, rules) == token + } + + // irregular rules + init { + // Pronouns. + addIrregularRule("I", "we") + addIrregularRule("me", "us") + addIrregularRule("he", "they") + addIrregularRule("she", "they") + addIrregularRule("them", "them") + addIrregularRule("myself", "ourselves") + addIrregularRule("yourself", "yourselves") + addIrregularRule("itself", "themselves") + addIrregularRule("herself", "themselves") + addIrregularRule("himself", "themselves") + addIrregularRule("themself", "themselves") + addIrregularRule("is", "are") + addIrregularRule("was", "were") + addIrregularRule("has", "have") + addIrregularRule("this", "these") + addIrregularRule("that", "those") + // Words ending in with a consonant and `o`. + addIrregularRule("echo", "echoes") + addIrregularRule("dingo", "dingoes") + addIrregularRule("volcano", "volcanoes") + addIrregularRule("tornado", "tornadoes") + addIrregularRule("torpedo", "torpedoes") + // Ends with `us`. + addIrregularRule("genus", "genera") + addIrregularRule("viscus", "viscera") + // Ends with `ma`. + addIrregularRule("stigma", "stigmata") + addIrregularRule("stoma", "stomata") + addIrregularRule("dogma", "dogmata") + addIrregularRule("lemma", "lemmata") + addIrregularRule("schema", "schemata") + addIrregularRule("anathema", "anathemata") + // Other irregular rules. + addIrregularRule("ox", "oxen") + addIrregularRule("axe", "axes") + addIrregularRule("die", "dice") + addIrregularRule("yes", "yeses") + addIrregularRule("foot", "feet") + addIrregularRule("eave", "eaves") + addIrregularRule("goose", "geese") + addIrregularRule("tooth", "teeth") + addIrregularRule("quiz", "quizzes") + addIrregularRule("human", "humans") + addIrregularRule("proof", "proofs") + addIrregularRule("carve", "carves") + addIrregularRule("valve", "valves") + addIrregularRule("looey", "looies") + addIrregularRule("thief", "thieves") + addIrregularRule("groove", "grooves") + addIrregularRule("pickaxe", "pickaxes") + addIrregularRule("passerby", "passersby") + } + + // pluralization rules + init { + addPluralRule("""s?$""".toRegex(), "s") + addPluralRule("""[^\u0000-\u007F]$""".toRegex(), "$0") + addPluralRule("""([^aeiou]ese)$""".toRegex(), "$1") + addPluralRule("""(ax|test)is$""".toRegex(), "$1es") + addPluralRule("""(alias|[^aou]us|t[lm]as|gas|ris)$""".toRegex(), "$1es") + addPluralRule("""(e[mn]u)s?$""".toRegex(), "$1s") + addPluralRule("""([^l]ias|[aeiou]las|[ejzr]as|[iu]am)$""".toRegex(), "$1") + addPluralRule("""(alumn|syllab|vir|radi|nucle|fung|cact|stimul|termin|bacill|foc|uter|loc|strat)(?:us|i)$""".toRegex(), "$1i") + addPluralRule("""(alumn|alg|vertebr)(?:a|ae)$""".toRegex(), "$1ae") + addPluralRule("""(seraph|cherub)(?:im)?$""".toRegex(), "$1im") + addPluralRule("""(her|at|gr)o$""".toRegex(), "$1oes") + addPluralRule("""(agend|addend|millenni|dat|extrem|bacteri|desiderat|strat|candelabr|errat|ov|symposi|curricul|automat|quor)(?:a|um)$""".toRegex(), "$1a") + addPluralRule("""(apheli|hyperbat|periheli|asyndet|noumen|phenomen|criteri|organ|prolegomen|hedr|automat)(?:a|on)$""".toRegex(), "$1a") + addPluralRule("""sis$""".toRegex(), "ses") + addPluralRule("""(?:(kni|wi|li)fe|(ar|l|ea|eo|oa|hoo)f)$""".toRegex(), "$1$2ves") + addPluralRule("""([^aeiouy]|qu)y$""".toRegex(), "$1ies") + addPluralRule("""([^ch][ieo][ln])ey$""".toRegex(), "$1ies") + addPluralRule("""(x|ch|ss|sh|zz)$""".toRegex(), "$1es") + addPluralRule("""(matr|cod|mur|sil|vert|ind|append)(?:ix|ex)$""".toRegex(), "$1ices") + addPluralRule("""\b((?:tit)?m|l)(?:ice|ouse)$""".toRegex(), "$1ice") + addPluralRule("""(pe)(?:rson|ople)$""".toRegex(), "$1ople") + addPluralRule("""(child)(?:ren)?$""".toRegex(), "$1ren") + addPluralRule("""eaux$""".toRegex(), "$0") + addPluralRule("""m[ae]n$""".toRegex(), "men") + addPluralRule("""^thou$""".toRegex(), "you") + } + + // singularization rules + init { + addSingularRule("""s$""".toRegex(), "") + addSingularRule("""(ss)$""".toRegex(), "$1") + addSingularRule("""(wi|kni|(?:after|half|high|low|mid|non|night|[^\w]|^)li)ves$""".toRegex(), "$1fe") + addSingularRule("""(ar|(?:wo|[ae])l|[eo][ao])ves$""".toRegex(), "$1f") + addSingularRule("""ies$""".toRegex(), "y") + addSingularRule("""(dg|ss|ois|lk|ok|wn|mb|th|ch|ec|oal|is|ec|ck|ix|sser|ts|wb)ies$""".toRegex(), "$1ie") + addSingularRule("""\b(l|(?:neck|cross|hog|aun)?t|coll|faer|food|gen|goon|group|hipp|junk|vegg|(?:pork)?p|charl|calor|cut)ies$""".toRegex(), "$1ie") + addSingularRule("""\b(mon|smil)ies$""".toRegex(), "$1ey") + addSingularRule("""\b((?:tit)?m|l)ice$""".toRegex(), "$1ouse") + addSingularRule("""(seraph|cherub)im$""".toRegex(), "$1") + addSingularRule("""(x|ch|ss|sh|zz|tto|go|cho|alias|[^aou]us|t[lm]as|gas|(?:her|at|gr)o|[aeiou]ris)(?:es)?$""".toRegex(), "$1") + addSingularRule("""(analy|diagno|parenthe|progno|synop|the|empha|cri|ne)(?:sis|ses)$""".toRegex(), "$1sis") + addSingularRule("""(movie|twelve|abuse|e[mn]u)s$""".toRegex(), "$1") + addSingularRule("""(test)(?:is|es)$""".toRegex(), "$1is") + addSingularRule("""(alumn|syllab|vir|radi|nucle|fung|cact|stimul|termin|bacill|foc|uter|loc|strat)(?:us|i)$""".toRegex(), "$1us") + addSingularRule("""(agend|addend|millenni|dat|extrem|bacteri|desiderat|strat|candelabr|errat|ov|symposi|curricul|quor)a$""".toRegex(), "$1um") + addSingularRule("""(apheli|hyperbat|periheli|asyndet|noumen|phenomen|criteri|organ|prolegomen|hedr|automat)a$""".toRegex(), "$1on") + addSingularRule("""(alumn|alg|vertebr)ae$""".toRegex(), "$1a") + addSingularRule("""(cod|mur|sil|vert|ind)ices$""".toRegex(), "$1ex") + addSingularRule("""(matr|append)ices$""".toRegex(), "$1ix") + addSingularRule("""(pe)(rson|ople)$""".toRegex(), "$1rson") + addSingularRule("""(child)ren$""".toRegex(), "$1") + addSingularRule("""(eau)x?$""".toRegex(), "$1") + addSingularRule("""men$""".toRegex(), "man") + } + + // uncountable + init { + // Singular words with no plurals. + addUncountableWord("adulthood") + addUncountableWord("advice") + addUncountableWord("agenda") + addUncountableWord("aid") + addUncountableWord("aircraft") + addUncountableWord("alcohol") + addUncountableWord("ammo") + addUncountableWord("analytics") + addUncountableWord("anime") + addUncountableWord("athletics") + addUncountableWord("audio") + addUncountableWord("bison") + addUncountableWord("blood") + addUncountableWord("bream") + addUncountableWord("buffalo") + addUncountableWord("butter") + addUncountableWord("carp") + addUncountableWord("cash") + addUncountableWord("chassis") + addUncountableWord("chess") + addUncountableWord("clothing") + addUncountableWord("cod") + addUncountableWord("commerce") + addUncountableWord("cooperation") + addUncountableWord("corps") + addUncountableWord("debris") + addUncountableWord("diabetes") + addUncountableWord("digestion") + addUncountableWord("elk") + addUncountableWord("energy") + addUncountableWord("equipment") + addUncountableWord("excretion") + addUncountableWord("expertise") + addUncountableWord("firmware") + addUncountableWord("flounder") + addUncountableWord("fun") + addUncountableWord("gallows") + addUncountableWord("garbage") + addUncountableWord("graffiti") + addUncountableWord("hardware") + addUncountableWord("headquarters") + addUncountableWord("health") + addUncountableWord("herpes") + addUncountableWord("highjinks") + addUncountableWord("homework") + addUncountableWord("housework") + addUncountableWord("information") + addUncountableWord("jeans") + addUncountableWord("justice") + addUncountableWord("kudos") + addUncountableWord("labour") + addUncountableWord("literature") + addUncountableWord("machinery") + addUncountableWord("mackerel") + addUncountableWord("mail") + addUncountableWord("media") + addUncountableWord("mews") + addUncountableWord("moose") + addUncountableWord("music") + addUncountableWord("mud") + addUncountableWord("manga") + addUncountableWord("news") + addUncountableWord("only") + addUncountableWord("personnel") + addUncountableWord("pike") + addUncountableWord("plankton") + addUncountableWord("pliers") + addUncountableWord("police") + addUncountableWord("pollution") + addUncountableWord("premises") + addUncountableWord("rain") + addUncountableWord("research") + addUncountableWord("rice") + addUncountableWord("salmon") + addUncountableWord("scissors") + addUncountableWord("series") + addUncountableWord("sewage") + addUncountableWord("shambles") + addUncountableWord("shrimp") + addUncountableWord("software") + addUncountableWord("staff") + addUncountableWord("swine") + addUncountableWord("tennis") + addUncountableWord("traffic") + addUncountableWord("transportation") + addUncountableWord("trout") + addUncountableWord("tuna") + addUncountableWord("wealth") + addUncountableWord("welfare") + addUncountableWord("whiting") + addUncountableWord("wildebeest") + addUncountableWord("wildlife") + addUncountableWord("you") + addUncountableRule("""pok[eé]mon$""".toRegex()) + // Regexes. + addUncountableRule("""[^aeiou]ese$""".toRegex()) // "chinese", "japanese" + addUncountableRule("""deer$""".toRegex()) // "deer", "reindeer" + addUncountableRule("""fish$""".toRegex()) // "fish", "blowfish", "angelfish" + addUncountableRule("""measles$""".toRegex()) + addUncountableRule("""o[iu]s$""".toRegex()) // "carnivorous" + addUncountableRule("""pox$""".toRegex()) // "chickpox", "smallpox" + addUncountableRule("""sheep$""".toRegex()) + } + + private data class Rule(val regex: Regex, val replace: String) +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/uri/JdbcURIBuilder.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/uri/JdbcURIBuilder.kt new file mode 100644 index 00000000..a714a1f9 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/uri/JdbcURIBuilder.kt @@ -0,0 +1,93 @@ +package com.bybutter.sisyphus.uri + +import java.net.URI + +class JdbcURIBuilder internal constructor(uri: URI) { + private val scheme = mutableListOf() + val schemePart: List = scheme + val builder: URIBuilder + + init { + if (uri.scheme != "jdbc") { + throw IllegalArgumentException("Wrong JDBC URI '$uri'.") + } + var resolvedUri = uri + while (resolvedUri.scheme != null) { + scheme += resolvedUri.scheme + resolvedUri = URI.create(resolvedUri.schemeSpecificPart) + } + builder = resolvedUri.toBuilder() + } + var userInfo: String? + get() = builder.userInfo + set(value) { + builder.setUserInfo(value ?: return) + } + var host: String? + get() = builder.host + set(value) { + builder.setHost(value ?: return) + } + var port: Int + get() = builder.port + set(value) { + builder.setPort(value) + } + var path: String? + get() = builder.path + set(value) { + builder.setPath(value ?: return) + } + val query get() = builder.query + + fun appendQueryPart(key: String, vararg value: String): JdbcURIBuilder { + builder.appendQueryPart(key, *value) + return this + } + + fun appendQueryPart(key: String, value: Iterable): JdbcURIBuilder { + builder.appendQueryPart(key, value) + return this + } + + fun clearQuery(): JdbcURIBuilder { + builder.clearQuery() + return this + } + + fun setQueryPart(query: String): JdbcURIBuilder { + builder.setQueryPart(query) + return this + } + + fun getQuery(key: String): List { + return builder.getQuery(key) + } + + fun build(): URI { + val ssp = buildString { + for (scheme in schemePart.subList(1, schemePart.size)) { + append(scheme) + append(":") + } + append("//") + append(builder) + } + return URI(scheme.first(), ssp, null) + } + + override fun toString(): String { + return buildString { + for (scheme in schemePart) { + append(scheme) + append(":") + } + append("//") + append(builder) + } + } +} + +fun URI.toJdbcBuilder(): JdbcURIBuilder { + return JdbcURIBuilder(this) +} diff --git a/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/uri/URIBuilder.kt b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/uri/URIBuilder.kt new file mode 100644 index 00000000..5e1130e2 --- /dev/null +++ b/lib/sisyphus-common/src/main/kotlin/com/bybutter/sisyphus/uri/URIBuilder.kt @@ -0,0 +1,192 @@ +package com.bybutter.sisyphus.uri + +import com.bybutter.sisyphus.data.urlDecode +import com.bybutter.sisyphus.data.urlEncode +import java.net.URI + +class URIBuilder { + // Common part + var scheme: String? = null + private set + var fragment: String? = null + private set + + // Opaque URI + var schemeSpecificPart: String? = null + private set + + // Hierarchical URI + var authority: String? = null + private set + var path: String? = null + private set + val query get() = buildQuery() + private val queryMap: MutableMap> = mutableMapOf() + + // Server-based Authority + var userInfo: String? = null + private set + var host: String? = null + private set + var port: Int = -1 + private set + + val isAbsolute get() = scheme != null + + val isOpaque get() = schemeSpecificPart != null + + internal constructor(uri: URI) { + scheme = uri.scheme + fragment = uri.fragment + + if (uri.isOpaque) { + schemeSpecificPart = uri.schemeSpecificPart + } else { + authority = uri.authority + path = uri.path + queryMap += parseQuery(uri.query) + + uri.userInfo?.let { + userInfo = it + } + uri.host?.let { + host = it + } + uri.port.let { + port = it + } + } + } + + constructor(scheme: String) { + this.scheme = scheme + } + + private fun parseQuery(query: String): MutableMap> { + return query.split('&').asSequence().mapNotNull { + val data = it.split('=') + if (data.size != 2) return@mapNotNull null + data[0].urlDecode() to data[1].urlDecode() + }.groupBy { it.first }.mapValues { + it.value.map { it.second }.toMutableList() + }.toMutableMap() + } + + fun setScheme(scheme: String): URIBuilder { + this.scheme = scheme + return this + } + + fun setFragment(fragment: String): URIBuilder { + this.fragment = fragment.urlEncode() + return this + } + + fun setAuthority(authority: String): URIBuilder { + if (isOpaque) throw IllegalStateException("Opaque URI has no authority.") + this.authority = authority + normalizeAuthority(false) + return this + } + + private fun normalizeAuthority(baseUserInfo: Boolean) { + if (baseUserInfo) { + val testURI = URI("test", userInfo, host, -1, "/", null, null) + authority = testURI.authority + } else { + val testURI = URI("test", authority, "/", null, null) + userInfo = testURI.userInfo + host = testURI.host + port = testURI.port + } + } + + fun setUserInfo(user: String, password: String): URIBuilder { + return setUserInfo("${user.urlEncode()}:${password.urlEncode()}") + } + + fun setUserInfo(userInfo: String): URIBuilder { + if (isOpaque) throw IllegalStateException("Opaque URI has no user info.") + this.userInfo = userInfo + normalizeAuthority(true) + return this + } + + fun setHost(host: String): URIBuilder { + if (isOpaque) throw IllegalStateException("Opaque URI has no port.") + this.host = host + normalizeAuthority(true) + return this + } + + fun setPort(port: Int): URIBuilder { + if (isOpaque) throw IllegalStateException("Opaque URI has no port.") + this.port = port + normalizeAuthority(true) + return this + } + + fun setPath(path: String): URIBuilder { + if (isOpaque) throw IllegalStateException("Opaque URI has no path.") + this.path = path + return this + } + + fun appendQueryPart(key: String, vararg value: String): URIBuilder { + return appendQueryPart(key, value.asSequence().asIterable()) + } + + fun appendQueryPart(key: String, value: Iterable): URIBuilder { + if (isOpaque) throw IllegalStateException("Opaque URI has no query.") + queryMap.getOrPut(key) { mutableListOf() } += value.map { it.urlEncode() } + return this + } + + fun clearQuery(): URIBuilder { + queryMap.clear() + return this + } + + fun setQueryPart(key: String): URIBuilder { + if (isOpaque) throw IllegalStateException("Opaque URI has no query.") + queryMap.clear() + queryMap += parseQuery(key) + return this + } + + fun getQuery(query: String): List { + return queryMap.getOrPut(query) { mutableListOf() } + } + + private fun buildQuery(): String? { + val result = buildString { + for ((key, values) in queryMap) { + for (value in values) { + if (this.isNotEmpty()) { + append("&") + } + append(key) + append("=") + append(value) + } + } + } + return if (result.isEmpty()) null else result + } + + fun build(): URI { + return if (isOpaque) { + URI(scheme, schemeSpecificPart, fragment) + } else { + URI(scheme, authority, path, query, fragment) + } + } + + override fun toString(): String { + return build().toString() + } +} + +fun URI.toBuilder(): URIBuilder { + return URIBuilder(this) +} diff --git a/lib/sisyphus-dto/build.gradle.kts b/lib/sisyphus-dto/build.gradle.kts new file mode 100644 index 00000000..4b58632f --- /dev/null +++ b/lib/sisyphus-dto/build.gradle.kts @@ -0,0 +1,12 @@ +lib + +plugins { + `java-library` +} + +dependencies { + compileOnly(project(":lib:sisyphus-jackson")) + implementation(project(":lib:sisyphus-common")) + + implementation(Dependencies.Kotlin.reflect) +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/CachedReflection.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/CachedReflection.kt new file mode 100644 index 00000000..61fe49a0 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/CachedReflection.kt @@ -0,0 +1,59 @@ +package com.bybutter.sisyphus.dto + +import com.bybutter.sisyphus.reflect.instance +import com.bybutter.sisyphus.reflect.uncheckedCast +import java.lang.reflect.Method +import kotlin.reflect.KMutableProperty1 +import kotlin.reflect.KProperty1 + +interface CachedReflection { + val properties: Map> + val getters: Map> + val setters: Map> + val defaultValue: Map + val dtoValidators: List + val propertyValidators: Map> + val getterHooks: Map> + val setterHooks: Map> + val notNullProperties: Set> +} + +data class DtoValidating( + val raw: DtoValidation, + val instance: DtoValidator = raw.validator.instance().uncheckedCast() +) + +data class PropertyValidating( + val raw: PropertyValidation, + val instance: PropertyValidator = raw.validator.instance().uncheckedCast() +) + +data class PropertyHooking( + val raw: PropertyHook, + val instance: PropertyHookHandler = raw.value.instance().uncheckedCast() +) + +data class DefaultValueAssigning( + val raw: DefaultValue, + val instance: DefaultValueProvider = raw.valueProvider.instance().uncheckedCast() +) + +internal fun DtoValidation.resolve(): DtoValidating { + return DtoValidating(this) +} + +internal fun PropertyValidation.resolve(): PropertyValidating { + return PropertyValidating(this) +} + +internal fun PropertyHook.resolve(): PropertyHooking { + return PropertyHooking(this) +} + +internal fun DefaultValue.resolve(): DefaultValueAssigning { + return if (this.valueProvider == DefaultValueProvider::class) { + DefaultValueAssigning(this, DefaultValueProvider.Default) + } else { + DefaultValueAssigning(this) + } +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DefaultValue.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DefaultValue.kt new file mode 100644 index 00000000..f7b6ed48 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DefaultValue.kt @@ -0,0 +1,36 @@ +package com.bybutter.sisyphus.dto + +import kotlin.reflect.KClass + +/** + * Default value for dto properties, implements custom [DefaultValueProvider] + * to create custom type value. + * + * If [valueProvider] not be provided, it can convert basic type only. Any other + * type will be case a exception. + */ +@Target(AnnotationTarget.PROPERTY_GETTER) +annotation class DefaultValue( + /** + * The string value of default value, you should implements [DefaultValueProvider] + * to support non-basic type. + */ + val value: String = "", + val assign: Boolean = false, + /** + * The provider which can convert string value to real type value. + * + * If not provided, the default provider can convert string value to basic type. + * Here is all supported type and convert function used in default implementation: + * - Long: [String.toLong] + * - Int: [String.toInt] + * - Short: [String.toShort] + * - Byte: [String.toByte] + * - Char: [String.first] + * - Boolean: [String.toBoolean] + * - Float: [String.toFloat] + * - Double: [String.toDouble] + * - String: [String.toString] + */ + val valueProvider: KClass> = DefaultValueProvider::class +) diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DefaultValueProvider.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DefaultValueProvider.kt new file mode 100644 index 00000000..354dbb1c --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DefaultValueProvider.kt @@ -0,0 +1,170 @@ +package com.bybutter.sisyphus.dto + +import com.bybutter.sisyphus.dto.enums.IntEnum +import com.bybutter.sisyphus.dto.enums.StringEnum +import com.bybutter.sisyphus.reflect.uncheckedCast +import com.bybutter.sisyphus.security.base64Decode +import com.bybutter.sisyphus.security.base64UrlSafeDecode +import kotlin.reflect.KProperty +import kotlin.reflect.full.isSuperclassOf +import kotlin.reflect.jvm.jvmErasure + +/** + * The provider which can convert string value to real type value. + */ +interface DefaultValueProvider { + fun getValue(proxy: ModelProxy, param: String, property: KProperty): T? + + object Default : DefaultValueProvider { + override fun getValue( + proxy: ModelProxy, + param: String, + property: KProperty + ): Any? { + return when (property.returnType.classifier) { + Long::class -> { + if (param.isEmpty()) { + 0L + } else { + param.toLong() + } + } + Int::class -> { + if (param.isEmpty()) { + 0 + } else { + param.toInt() + } + } + Short::class -> { + if (param.isEmpty()) { + 0.toShort() + } else { + param.toShort() + } + } + Byte::class -> { + if (param.isEmpty()) { + 0.toByte() + } else { + param.toByte() + } + } + ULong::class -> { + if (param.isEmpty()) { + 0.toULong() + } else { + param.toULong() + } + } + UInt::class -> { + if (param.isEmpty()) { + 0.toUInt() + } else { + param.toUInt() + } + } + UShort::class -> { + if (param.isEmpty()) { + 0.toUShort() + } else { + param.toUShort() + } + } + UByte::class -> { + if (param.isEmpty()) { + 0.toUByte() + } else { + param.toUByte() + } + } + Char::class -> { + if (param.isEmpty()) { + 0.toChar() + } else { + param[0] + } + } + Boolean::class -> { + if (param.isEmpty()) { + false + } else { + param.toBoolean() + } + } + Float::class -> { + if (param.isEmpty()) { + 0.0f + } else { + param.toFloat() + } + } + Double::class -> { + if (param.isEmpty()) { + 0.0 + } else { + param.toDouble() + } + } + String::class -> { + param + } + ByteArray::class -> { + if (param.contains("""[+/]""".toRegex())) { + param.base64Decode() + } else { + param.base64UrlSafeDecode() + } + } + MutableList::class -> { + if (param.isEmpty()) { + mutableListOf() + } else { + throw UnsupportedOperationException("Unsupported default value type(${property.returnType})(${proxy.`$type`}).") + } + } + MutableMap::class -> { + if (param.isEmpty()) { + mutableMapOf() + } else { + throw UnsupportedOperationException("Unsupported default value type(${property.returnType})(${proxy.`$type`}).") + } + } + List::class -> { + if (param.isEmpty()) { + emptyList() + } else { + throw UnsupportedOperationException("Unsupported default value type(${property.returnType})(${proxy.`$type`}).") + } + } + Map::class -> { + if (param.isEmpty()) { + emptyMap() + } else { + throw UnsupportedOperationException("Unsupported default value type(${property.returnType})(${proxy.`$type`}).") + } + } + else -> { + when { + IntEnum::class.isSuperclassOf(property.returnType.classifier.uncheckedCast()) -> { + if (param.toIntOrNull() != null) { + IntEnum.valueOf(param.toInt(), property.returnType.jvmErasure.java.uncheckedCast>()) + } else { + property.returnType.jvmErasure.java.enumConstants.firstOrNull { + (it as Enum<*>).name == param + } + } + } + StringEnum::class.isSuperclassOf(property.returnType.classifier.uncheckedCast()) -> { + StringEnum.valueOf(param, property.returnType.jvmErasure.java.uncheckedCast>()) + ?: property.returnType.jvmErasure.java.enumConstants.firstOrNull { + (it as Enum<*>).name == param + } + } + else -> throw UnsupportedOperationException("Unsupported default value type(${property.returnType})(${proxy.`$type`}).") + } + } + } + } + } +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoDsl.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoDsl.kt new file mode 100644 index 00000000..ee5a03ba --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoDsl.kt @@ -0,0 +1,47 @@ +package com.bybutter.sisyphus.dto + +import com.bybutter.sisyphus.reflect.SimpleType +import com.bybutter.sisyphus.reflect.getTypeArgument +import com.bybutter.sisyphus.reflect.jvm +import java.util.HashMap + +open class DtoDsl { + val type: SimpleType by lazy { + this.javaClass.getTypeArgument(DtoDsl::class.java, 0).jvm as SimpleType + } + + /** + * Create DTO instance with init body. + */ + inline operator fun invoke(block: T.() -> Unit): T { + return DtoModel(type, block) + } + + /** + * Create DTO instance. + */ + operator fun invoke(): T { + return DtoModel(type) + } + + /** + * Create DTO instance with shallow copy. + */ + operator fun invoke(model: DtoModel): T { + return DtoModel(type) { + val map = HashMap((model as DtoMeta).`$modelMap`) + (this as DtoMeta).`$modelMap` = map + } + } + + /** + * Create DTO instance with shallow copy and init body. + */ + inline operator fun invoke(model: DtoModel, block: T.() -> Unit): T { + return DtoModel(type) { + val map = HashMap((model as DtoMeta).`$modelMap`) + (this as DtoMeta).`$modelMap` = map + block(this) + } + } +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoMeta.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoMeta.kt new file mode 100644 index 00000000..b07a9b31 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoMeta.kt @@ -0,0 +1,46 @@ +package com.bybutter.sisyphus.dto + +import java.lang.reflect.Proxy +import java.lang.reflect.Type + +/** + * Meta info for dto object, all dto instance can be cast to [DtoMeta]. + * It provide the unsafe and basic access for dto objects. + */ +interface DtoMeta { + /** + * The dto object real type. + */ + @get:MetaProperty + val `$type`: Type + + /** + * The map which used for store all properties for dto. + */ + @get:MetaProperty + var `$modelMap`: MutableMap + + /** + * If current dot object should contain type info in json or other data format. + */ + @get:MetaProperty + var `$outputType`: Boolean + + /** + * Get the properties value for dto object, no hooks, no default values. + */ + operator fun get(name: String): T? + + /** + * Set the properties value for dto object, no hooks. + */ + operator fun set(name: String, value: T?) +} + +fun DtoModel.hasProperty(name: String): Boolean { + return (this as DtoMeta).hasProperty(name) +} + +fun DtoMeta.hasProperty(name: String): Boolean { + return this.`$modelMap`.containsKey(name) && (Proxy.getInvocationHandler(this) as ModelProxy).properties.containsKey(name) +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoModel.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoModel.kt new file mode 100644 index 00000000..4278a152 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoModel.kt @@ -0,0 +1,152 @@ +package com.bybutter.sisyphus.dto + +import com.bybutter.sisyphus.reflect.SimpleType +import com.bybutter.sisyphus.reflect.TypeReference +import com.bybutter.sisyphus.reflect.instance +import com.bybutter.sisyphus.reflect.jvm +import com.bybutter.sisyphus.reflect.uncheckedCast +import java.lang.reflect.Proxy +import java.util.HashMap + +/** + * [DtoModel] is the root class of DTOs, it provides the json deserializer for jackson. + * + * Data transfer object(DTO) is the model which is used in API request and response. + * + * All of DTOs is interface, all instance will be built by [ModelFactory] in JAVA or [DtoModel] in Kotlin, + * you could be not implementing this interface, all of the logic will be handled by dynamic proxy. + * + * @see PropertyHook + * @see DtoMeta + * @see DtoModel.Companion + */ +interface DtoModel { + /** + * Invokable companion of [DtoModel], provide DSL for create DTO instance. + */ + companion object { + val proxyCache = mutableMapOf>() + + /** + * Create DTO instance with init body. + */ + inline operator fun invoke(block: T.() -> Unit): T { + return invoke(object : TypeReference() {}, block) + } + + /** + * Create DTO instance. + */ + inline operator fun invoke(): T { + return invoke(object : TypeReference() {}) + } + + /** + * Create DTO instance with shallow copy. + */ + inline operator fun invoke(model: DtoModel): T { + return invoke { + val map = HashMap((model as DtoMeta).`$modelMap`) + (this as DtoMeta).`$modelMap` = map + } + } + + /** + * Create DTO instance with shallow copy and init body. + */ + inline operator fun invoke(model: DtoModel, block: T.() -> Unit): T { + return invoke { + val map = HashMap((model as DtoMeta).`$modelMap`) + (this as DtoMeta).`$modelMap` = map + block(this) + } + } + + operator fun invoke(typeReference: TypeReference): T { + return invoke(typeReference.type.jvm as SimpleType) + } + + inline operator fun invoke(typeReference: TypeReference, block: T.() -> Unit): T { + return invoke(typeReference.type.jvm as SimpleType, block) + } + + operator fun invoke(type: SimpleType): T { + val value = (proxyCache[type]?.instance() ?: Proxy.newProxyInstance( + type.raw.classLoader, + arrayOf(type.raw, DtoMeta::class.java), + ModelProxy(type) + )).uncheckedCast() + value.verify() + return value + } + + inline operator fun invoke( + type: SimpleType, + block: T.() -> Unit + ): T { + val value = (proxyCache[type]?.instance() ?: Proxy.newProxyInstance( + type.raw.classLoader, + arrayOf(type.raw, DtoMeta::class.java), + ModelProxy(type) + )).uncheckedCast() + value.apply(block) + value.verify() + return value + } + } + + fun verify() +} + +val DtoModel.isValid: Boolean + get() { + val proxy = Proxy.getInvocationHandler(this) as ModelProxy + return proxy.isValid + } + +/** + * Cast a DTO instance to other type, all DTO can be cast to each other. + * + * **Attention! The result of [this method][cast] is not the origin object, but origin object and result object will share the structure of content.** + */ +inline fun DtoModel.cast(noinline apply: T.() -> Unit = {}): T { + return DtoModel(TypeReference()) { + this.uncheckedCast().`$modelMap` = this@cast.uncheckedCast().`$modelMap` + apply(this) + } +} + +/** + * Cast a DTO instance to other type, all DTO can be cast to each other. + * + * **Attention! The result of [this method][cast] is not the origin object, but origin object and result object will share the structure of content.** + */ +fun DtoModel.castTo(clazz: Class, apply: T.() -> Unit = {}): T { + return DtoModel(clazz.jvm) { + this.uncheckedCast().`$modelMap` = this@castTo.uncheckedCast().`$modelMap` + apply(this) + } +} + +/** + * Erase type info for a DTO instance, it will **not contains** `$type` property for serialized json. + * + * @see DtoModel.withType + */ +fun T.eraseType(): T where T : DtoModel { + this.uncheckedCast().`$outputType` = false + return this +} + +/** + * Add type info for a DTO instance, it will **contains** `$type` property for serialized json. + * + * @see DtoModel.eraseType + */ +fun T.withType(): T where T : DtoModel { + this.uncheckedCast().`$outputType` = true + return this +} + +val DtoModel.proxy: ModelProxy + get() = Proxy.getInvocationHandler(this) as ModelProxy diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoValidateException.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoValidateException.kt new file mode 100644 index 00000000..30ddb088 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoValidateException.kt @@ -0,0 +1,9 @@ +package com.bybutter.sisyphus.dto + +class DtoValidateException : Exception { + constructor(message: String, cause: Throwable) : super(message, cause) + + constructor(cause: Throwable) : super("Dto validate fail", cause) + + constructor(message: String) : super(message) +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoValidation.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoValidation.kt new file mode 100644 index 00000000..dc56ace9 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoValidation.kt @@ -0,0 +1,14 @@ +package com.bybutter.sisyphus.dto + +import kotlin.reflect.KClass + +/** + * Dto object validating annotation, [validator] will be called with [DtoModel.verify] and [DtoModel.isValid]. + */ +@Repeatable +@Target(AnnotationTarget.CLASS) +annotation class DtoValidation(val validator: KClass>, val params: Array = []) + +@Repeatable +@Target(AnnotationTarget.CLASS) +annotation class DtoValidations(val validations: Array) diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoValidator.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoValidator.kt new file mode 100644 index 00000000..ad546783 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/DtoValidator.kt @@ -0,0 +1,5 @@ +package com.bybutter.sisyphus.dto + +interface DtoValidator { + fun verify(value: T, params: Array): Exception? +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/MetaProperty.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/MetaProperty.kt new file mode 100644 index 00000000..8e7e9199 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/MetaProperty.kt @@ -0,0 +1,7 @@ +package com.bybutter.sisyphus.dto + +/** + * Mark a dto property is meta property, it should not be serializing. + */ +@Target(AnnotationTarget.PROPERTY_GETTER) +annotation class MetaProperty diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/ModelProxy.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/ModelProxy.kt new file mode 100644 index 00000000..a37e82c3 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/ModelProxy.kt @@ -0,0 +1,151 @@ +package com.bybutter.sisyphus.dto + +import com.bybutter.sisyphus.reflect.SimpleType +import com.bybutter.sisyphus.reflect.uncheckedCast +import java.lang.ref.SoftReference +import java.lang.reflect.InvocationHandler +import java.lang.reflect.Method +import java.lang.reflect.Proxy + +open class ModelProxy constructor( + override val `$type`: SimpleType +) : InvocationHandler, DtoMeta, DtoModel, CachedReflection by ReflectionCache.get(`$type`) { + private var target: SoftReference? = null + + override var `$modelMap`: MutableMap = mutableMapOf() + + override var `$outputType`: Boolean = jsonTypeOutputHandler() + + override fun get(name: String): T? { + val property = properties[name] + + if (property == null) { + return `$modelMap`[name].uncheckedCast() + } else { + var value: Any? = `$modelMap`.getOrElse(property.name) { + val default = defaultValue[name] ?: return@getOrElse null + val defaultValue = default.instance.getValue(this, default.raw.value, property) + if (default.raw.assign) { + `$modelMap`[property.name] = defaultValue + } + defaultValue + } + + val hooks = getterHooks[name] ?: emptyList() + + value = hooks.fold(value) { v, it -> + it.instance.invoke(this, v, it.raw.params, property) + } + return value.uncheckedCast() + } + } + + override fun set(name: String, value: T?) { + val property = properties[name] + + if (property == null) { + `$modelMap`[name] = value + } else { + val hooks = setterHooks[name] ?: emptyList() + + val value = hooks.fold(value) { v, it -> + it.instance.invoke(this, v, it.raw.params, property).uncheckedCast() + } + propertyValidators[name]?.forEach { + it.instance.verify(this, value, it.raw.params, property) + } + + `$modelMap`[property.name] = value + } + } + + override fun invoke(proxy: Any, method: Method, args: Array?): Any? { + if (target == null) { + target = SoftReference(proxy) + } + + val validArgs = args ?: arrayOf() + + if (getters.containsKey(method)) { + val property = getters[method] ?: return null + return get(property.name) + } else if (setters.containsKey(method)) { + val property = setters[method] ?: return null + set(property.name, validArgs[0]) + return null + } + + return method.invoke(this, *validArgs) + } + + override fun equals(other: Any?): Boolean { + return if (other is DtoModel) { + super.equals(Proxy.getInvocationHandler(other)) + } else super.equals(other) + } + + override fun hashCode(): Int { + return super.hashCode() + } + + override fun toString(): String { + return `$type`.toString() + "@" + Integer.toHexString(hashCode()) + } + + private var exception: Exception? = null + + val isValid: Boolean + get() { + verifyInternal() + return exception == null + } + + override fun verify() { + verifyInternal() + throw DtoValidateException(exception ?: return) + } + + private fun verifyInternal() { + val property = notNullProperties.firstOrNull { + `$modelMap`[it.name] == null + } + + if (property != null) { + exception = NullPointerException("Property '$property' is null, but it be declared as not null.") + return + } + + for (dtoValidator in dtoValidators) { + val ex = dtoValidator.instance.verify(target?.get() as DtoModel, dtoValidator.raw.params) + if (ex != null) { + exception = ex + return + } + } + + for ((name, validators) in propertyValidators) { + val property = properties.getValue(name) + for (validator in validators) { + val ex = validator.instance.verify( + this, + get(name), + validator.raw.params, + property + ) + if (ex != null) { + exception = ex + return + } + } + } + + exception = null + return + } + + companion object { + var jsonTypeOutputHandler: () -> Boolean = { + true + } + } +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/NullableProperty.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/NullableProperty.kt new file mode 100644 index 00000000..b399d0a5 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/NullableProperty.kt @@ -0,0 +1,7 @@ +package com.bybutter.sisyphus.dto + +/** + * Mark a dto property is nullable, should be skip null check in dto validation. + */ +@Target(AnnotationTarget.PROPERTY_GETTER) +annotation class NullableProperty diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/PropertyHook.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/PropertyHook.kt new file mode 100644 index 00000000..00addf09 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/PropertyHook.kt @@ -0,0 +1,16 @@ +package com.bybutter.sisyphus.dto + +import kotlin.reflect.KClass + +/** + * Hooks for dto properties, it can be attached to getter, setter or both of them. + */ +@Repeatable +@Target(AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER, AnnotationTarget.ANNOTATION_CLASS) +annotation class PropertyHook( + val value: KClass> = PropertyHookHandler::class, + /** + * Parameters for [PropertyHookHandler] + */ + vararg val params: String = [] +) diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/PropertyHookHandler.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/PropertyHookHandler.kt new file mode 100644 index 00000000..faf0c09b --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/PropertyHookHandler.kt @@ -0,0 +1,10 @@ +package com.bybutter.sisyphus.dto + +import kotlin.reflect.KProperty + +/** + * Handler for dto property hooks. + */ +interface PropertyHookHandler { + operator fun invoke(target: Any, value: T, params: Array, property: KProperty): T +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/PropertyHooks.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/PropertyHooks.kt new file mode 100644 index 00000000..7cf25550 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/PropertyHooks.kt @@ -0,0 +1,4 @@ +package com.bybutter.sisyphus.dto + +@Target(AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER, AnnotationTarget.ANNOTATION_CLASS) +annotation class PropertyHooks(vararg val hooks: PropertyHook) diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/PropertyValidation.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/PropertyValidation.kt new file mode 100644 index 00000000..d9fbb89f --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/PropertyValidation.kt @@ -0,0 +1,12 @@ +package com.bybutter.sisyphus.dto + +import kotlin.reflect.KClass + +/** + * Dto property validating annotation, [validator] will be called with property setter, [DtoModel.verify] and [DtoModel.isValid]. + */ +@Target(AnnotationTarget.PROPERTY_SETTER) +annotation class PropertyValidation(val validator: KClass>, val params: Array = []) + +@Target(AnnotationTarget.PROPERTY_SETTER) +annotation class PropertyValidations(val validations: Array) diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/PropertyValidator.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/PropertyValidator.kt new file mode 100644 index 00000000..9fa0ecb5 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/PropertyValidator.kt @@ -0,0 +1,7 @@ +package com.bybutter.sisyphus.dto + +import kotlin.reflect.KProperty + +interface PropertyValidator { + fun verify(proxy: ModelProxy, value: T, params: Array, property: KProperty): Exception? +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/ReflectionCache.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/ReflectionCache.kt new file mode 100644 index 00000000..8e9f9bb7 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/ReflectionCache.kt @@ -0,0 +1,89 @@ +package com.bybutter.sisyphus.dto + +import com.bybutter.sisyphus.reflect.SimpleType +import com.bybutter.sisyphus.reflect.allProperties +import java.lang.reflect.Method +import kotlin.reflect.KMutableProperty1 +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.javaGetter +import kotlin.reflect.jvm.javaSetter + +/** + * Cache all needed reflection of dto objects. + */ +object ReflectionCache { + private val cache = mutableMapOf() + + private class CachedReflectionImpl(type: SimpleType) : CachedReflection { + override val defaultValue: Map + override val dtoValidators: List + override val getterHooks: Map> + override val setterHooks: Map> + override val propertyValidators: Map> + override val getters: Map> + override val setters: Map> + override val notNullProperties: Set> + override val properties: Map> + + init { + val memberProperties = type.raw.kotlin.memberProperties + val allProperties = type.raw.kotlin.allProperties + + this.properties = memberProperties.associateBy { it.name } + getters = allProperties.filter { it.javaGetter != null } + .associateBy { it.javaGetter!! } + setters = allProperties.mapNotNull { it as? KMutableProperty1 } + .filter { it.javaSetter != null } + .associateBy { it.javaSetter!! } + propertyValidators = memberProperties.mapNotNull { (it as? KMutableProperty1) } + .filter { it.javaSetter != null } + .associate { + it.name to listOfNotNull( + it.javaSetter!!.getAnnotation(PropertyValidation::class.java)?.resolve(), + *(it.javaSetter!!.getAnnotation(PropertyValidations::class.java)?.validations?.map { it.resolve() }?.toTypedArray() + ?: arrayOf()) + ) + } + dtoValidators = listOfNotNull( + type.raw.kotlin.findAnnotation()?.resolve(), + *(type.raw.kotlin.findAnnotation()?.validations?.map { it.resolve() }?.toTypedArray() + ?: arrayOf()) + ) + getterHooks = memberProperties.filter { it.javaGetter != null } + .associate { + it.name to listOfNotNull( + it.javaGetter!!.getAnnotation(PropertyHook::class.java)?.resolve(), + *(it.javaGetter!!.getAnnotation(PropertyHooks::class.java)?.hooks?.map { it.resolve() }?.toTypedArray() + ?: arrayOf()) + ) + } + setterHooks = memberProperties.mapNotNull { it as? KMutableProperty1<*, *> } + .filter { it.javaSetter != null } + .associate { + it.name to listOfNotNull( + it.javaSetter!!.getAnnotation(PropertyHook::class.java)?.resolve(), + *(it.javaSetter!!.getAnnotation(PropertyHooks::class.java)?.hooks?.map { it.resolve() }?.toTypedArray() + ?: arrayOf()) + ) + } + defaultValue = memberProperties.filter { it.javaGetter != null } + .associate { + it.name to it.javaGetter!!.getAnnotation(DefaultValue::class.java)?.resolve() + } + notNullProperties = memberProperties.filter { + !it.returnType.isMarkedNullable && + it.javaGetter?.getAnnotation(MetaProperty::class.java) == null && + it.javaGetter?.getAnnotation(NullableProperty::class.java) == null && + it.javaGetter?.getAnnotation(DefaultValue::class.java) == null + }.toSet() + } + } + + fun get(type: SimpleType): CachedReflection { + return cache.getOrPut(type) { + CachedReflectionImpl(type) + } + } +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/enums/EnumDsl.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/enums/EnumDsl.kt new file mode 100644 index 00000000..cb7b1fc1 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/enums/EnumDsl.kt @@ -0,0 +1,45 @@ +package com.bybutter.sisyphus.dto.enums + +interface IntEnumDsl { + operator fun invoke(value: Int): T + + fun valueOf(value: Int): T? + + companion object { + operator fun invoke(clazz: Class): IntEnumDsl { + return Impl(clazz) + } + } + + private class Impl(val clazz: Class) : IntEnumDsl { + override fun invoke(value: Int): T { + return IntEnum(value, clazz) + } + + override fun valueOf(value: Int): T? { + return IntEnum.valueOf(value, clazz) + } + } +} + +interface StringEnumDsl { + operator fun invoke(value: String): T + + fun valueOf(value: String): T? + + companion object { + operator fun invoke(clazz: Class): StringEnumDsl { + return Impl(clazz) + } + } + + private class Impl(val clazz: Class) : StringEnumDsl { + override fun invoke(value: String): T { + return StringEnum(value, clazz) + } + + override fun valueOf(value: String): T? { + return StringEnum.valueOf(value, clazz) + } + } +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/enums/IntEnum.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/enums/IntEnum.kt new file mode 100644 index 00000000..754c2fba --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/enums/IntEnum.kt @@ -0,0 +1,42 @@ +package com.bybutter.sisyphus.dto.enums + +import com.bybutter.sisyphus.reflect.uncheckedCast +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.JavaType +import com.fasterxml.jackson.databind.type.TypeFactory + +interface IntEnum { + val number: Int + + companion object { + fun valueOf(value: Int, type: Class): T? where T : IntEnum { + return valueOf(value, TypeFactory.defaultInstance().constructType(type)) + } + + fun valueOf(value: Int, type: TypeReference): T? where T : IntEnum { + return valueOf(value, TypeFactory.defaultInstance().constructType(type)) + } + + fun valueOf(value: Int, type: JavaType): T? where T : IntEnum { + val values = type.rawClass.enumConstants.map { it.uncheckedCast() } + return values.firstOrNull { it.number == value } ?: { + type.rawClass.declaredFields.filter { it.isEnumConstant && it.getDeclaredAnnotation(UnknownValue::class.java) != null } + .map { it.get(null).uncheckedCast() }.firstOrNull() + }() + } + + inline fun valueOf(value: Int): T? where T : IntEnum { + return valueOf(value, T::class.java) + } + + inline operator fun invoke(value: Int): T where T : IntEnum { + return valueOf(value) + ?: throw IllegalArgumentException("Can't found value($value) for int enum(${T::class.java.name}).") + } + + operator fun invoke(value: Int, type: Class): T where T : IntEnum { + return valueOf(value, type) + ?: throw IllegalArgumentException("Can't found value($value) for int enum(${type.name}).") + } + } +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/enums/StringEnum.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/enums/StringEnum.kt new file mode 100644 index 00000000..00f651ad --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/enums/StringEnum.kt @@ -0,0 +1,42 @@ +package com.bybutter.sisyphus.dto.enums + +import com.bybutter.sisyphus.reflect.uncheckedCast +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.JavaType +import com.fasterxml.jackson.databind.type.TypeFactory + +interface StringEnum { + val value: String + + companion object { + fun valueOf(value: String?, type: Class): T? where T : StringEnum { + return valueOf(value, TypeFactory.defaultInstance().constructType(type)) + } + + fun valueOf(value: String?, type: TypeReference): T? where T : StringEnum { + return valueOf(value, TypeFactory.defaultInstance().constructType(type)) + } + + fun valueOf(value: String?, type: JavaType): T? where T : StringEnum { + val values = type.rawClass.enumConstants.map { it.uncheckedCast() } + return values.firstOrNull { it.value == value } ?: run { + type.rawClass.declaredFields.filter { it.isEnumConstant && it.getDeclaredAnnotation(UnknownValue::class.java) != null } + .map { it.get(null).uncheckedCast() }.firstOrNull() + } + } + + inline fun valueOf(value: String?): T? where T : StringEnum { + return valueOf(value, T::class.java) + } + + inline operator fun invoke(value: String?): T where T : StringEnum { + return valueOf(value) + ?: throw IllegalArgumentException("Can't found value($value) for string enum(${T::class.java.name}).") + } + + operator fun invoke(value: String?, type: Class): T where T : StringEnum { + return valueOf(value, type) + ?: throw IllegalArgumentException("Can't found value($value) for string enum(${type.name}).") + } + } +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/enums/StringEnumDeserializer.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/enums/StringEnumDeserializer.kt new file mode 100644 index 00000000..961dda18 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/enums/StringEnumDeserializer.kt @@ -0,0 +1,29 @@ +package com.bybutter.sisyphus.dto.enums + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.BeanProperty +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JavaType +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.deser.ContextualDeserializer + +class StringEnumDeserializer : JsonDeserializer, ContextualDeserializer { + private var targetClass: JavaType? = null + + constructor() + constructor(targetClass: JavaType) { + if (!targetClass.rawClass.isEnum || !StringEnum::class.java.isAssignableFrom(targetClass.rawClass)) { + throw UnsupportedOperationException("Only support deserialize for 'BaseStringEnum'.") + } + this.targetClass = targetClass + } + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): StringEnum? { + val clazz = targetClass ?: throw RuntimeException("Target class is null.") + return StringEnum.valueOf(p.valueAsString, clazz) + } + + override fun createContextual(ctxt: DeserializationContext, property: BeanProperty?): JsonDeserializer<*> { + return StringEnumDeserializer(ctxt.contextualType) + } +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/enums/UnknownValue.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/enums/UnknownValue.kt new file mode 100644 index 00000000..9b500834 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/enums/UnknownValue.kt @@ -0,0 +1,4 @@ +package com.bybutter.sisyphus.dto.enums + +@Target(AnnotationTarget.FIELD) +annotation class UnknownValue diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/DtoAnnotationIntrospector.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/DtoAnnotationIntrospector.kt new file mode 100644 index 00000000..dec198a6 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/DtoAnnotationIntrospector.kt @@ -0,0 +1,15 @@ +package com.bybutter.sisyphus.dto.jackson + +import com.bybutter.sisyphus.dto.MetaProperty +import com.fasterxml.jackson.databind.introspect.AnnotatedMember +import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector + +internal object DtoAnnotationIntrospector : JacksonAnnotationIntrospector() { + override fun hasIgnoreMarker(m: AnnotatedMember): Boolean { + if (_findAnnotation(m, MetaProperty::class.java) != null) { + return true + } + + return super.hasIgnoreMarker(m) + } +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/DtoModule.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/DtoModule.kt new file mode 100644 index 00000000..25755d4c --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/DtoModule.kt @@ -0,0 +1,52 @@ +package com.bybutter.sisyphus.dto.jackson + +import com.bybutter.sisyphus.dto.DtoModel +import com.bybutter.sisyphus.reflect.JvmType +import com.fasterxml.jackson.databind.BeanDescription +import com.fasterxml.jackson.databind.DeserializationConfig +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonSerializer +import com.fasterxml.jackson.databind.SerializationConfig +import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.databind.ser.BeanSerializerModifier +import com.fasterxml.jackson.databind.ser.std.BeanSerializerBase +import java.lang.reflect.Type + +class DtoModule : SimpleModule() { + override fun setupModule(context: SetupContext) { + context.appendAnnotationIntrospector(DtoAnnotationIntrospector) + + context.addBeanSerializerModifier(object : BeanSerializerModifier() { + override fun modifySerializer( + config: SerializationConfig?, + beanDesc: BeanDescription, + serializer: JsonSerializer<*> + ): JsonSerializer<*> { + if (DtoModel::class.java.isAssignableFrom(beanDesc.beanClass)) { + return ModelSerializer(serializer as BeanSerializerBase) + } + if (Type::class.java.isAssignableFrom(beanDesc.beanClass)) { + return TypeSerializer() + } + return super.modifySerializer(config, beanDesc, serializer) + } + }) + + context.addBeanDeserializerModifier(object : BeanDeserializerModifier() { + override fun modifyDeserializer( + config: DeserializationConfig, + beanDesc: BeanDescription, + deserializer: JsonDeserializer<*> + ): JsonDeserializer<*> { + if (DtoModel::class.java.isAssignableFrom(beanDesc.beanClass)) { + return ModelDeserializer(beanDesc.type) + } + if (Type::class.java == beanDesc.beanClass || JvmType::class.java.isAssignableFrom(beanDesc.beanClass)) { + return TypeDeserializer() + } + return super.modifyDeserializer(config, beanDesc, deserializer) + } + }) + } +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/Extension.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/Extension.kt new file mode 100644 index 00000000..9b900360 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/Extension.kt @@ -0,0 +1,11 @@ +package com.bybutter.sisyphus.dto.jackson + +import com.bybutter.sisyphus.reflect.SimpleType +import com.bybutter.sisyphus.reflect.toType +import com.fasterxml.jackson.databind.JavaType + +// TODO: improve performance +val JavaType.jvm: SimpleType + get() { + return this.toCanonical().toType() as SimpleType + } diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/JsonDefaultValueProvider.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/JsonDefaultValueProvider.kt new file mode 100644 index 00000000..69207d32 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/JsonDefaultValueProvider.kt @@ -0,0 +1,18 @@ +package com.bybutter.sisyphus.dto.jackson + +import com.bybutter.sisyphus.dto.DefaultValueProvider +import com.bybutter.sisyphus.dto.ModelProxy +import com.bybutter.sisyphus.jackson.Json +import com.bybutter.sisyphus.reflect.jvm +import kotlin.reflect.KProperty +import kotlin.reflect.jvm.javaType + +class JsonDefaultValueProvider : DefaultValueProvider { + override fun getValue( + proxy: ModelProxy, + param: String, + property: KProperty + ): Any? { + return Json.deserialize(param, property.returnType.javaType.jvm) + } +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/ModelDeserializer.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/ModelDeserializer.kt new file mode 100644 index 00000000..18235b49 --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/ModelDeserializer.kt @@ -0,0 +1,68 @@ +package com.bybutter.sisyphus.dto.jackson + +import com.bybutter.sisyphus.dto.DtoModel +import com.bybutter.sisyphus.jackson.Json +import com.bybutter.sisyphus.jackson.javaType +import com.bybutter.sisyphus.reflect.SimpleType +import com.bybutter.sisyphus.reflect.toType +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.ObjectCodec +import com.fasterxml.jackson.core.TreeNode +import com.fasterxml.jackson.databind.BeanDescription +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JavaType +import com.fasterxml.jackson.databind.JsonDeserializer + +internal class ModelDeserializer(val targetClass: JavaType) : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): T { + val node = p.readValueAsTree() + val targetType = selectType(node, ctxt) + val beanDescription = ctxt.config.introspect(targetType) + + return DtoModel(targetType.toCanonical().toType() as SimpleType) { + deserializeInto(p.codec, node, beanDescription, this, ctxt) + } + } + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext, intoValue: T): T { + val node = Json.deserialize(p) + val targetType = selectType(node, ctxt) + + if (targetType.rawClass != intoValue.javaClass) { + return ctxt.readValue(p, targetType) + } + + val beanDescription = ctxt.config.introspect(targetType) + return deserializeInto(p.codec, node, beanDescription, intoValue, ctxt) + } + + private fun deserializeInto(codec: ObjectCodec, node: TreeNode, beanDescription: BeanDescription, intoValue: T, ctxt: DeserializationContext): T { + val properties = beanDescription.findProperties().associateBy { it.name } + + for (fieldName in node.fieldNames()) { + val property = properties[fieldName] ?: continue + + try { + val parser = node.get(fieldName).traverse(codec) + parser.nextToken() + property.setter.callOnWith(intoValue, ctxt.readValue(parser, property.getter.type)) + } catch (e: Exception) { + throw RuntimeException(e) + } + } + return intoValue + } + + private fun selectType(node: TreeNode, ctxt: DeserializationContext): JavaType { + val targetType = try { + ctxt.readValue(node.get("\$type").traverse(), JavaType::class.java.javaType) ?: targetClass + } catch (e: Exception) { + targetClass + } + + if (targetClass.isTypeOrSuperTypeOf(targetType.rawClass)) { + return targetType + } + return targetClass + } +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/ModelSerializer.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/ModelSerializer.kt new file mode 100644 index 00000000..fb8003bb --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/ModelSerializer.kt @@ -0,0 +1,78 @@ +package com.bybutter.sisyphus.dto.jackson + +import com.bybutter.sisyphus.dto.DtoMeta +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonStreamContext +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.impl.ObjectIdWriter +import com.fasterxml.jackson.databind.ser.std.BeanSerializerBase + +internal class ModelSerializer : BeanSerializerBase { + constructor(source: BeanSerializerBase) : super(source) + + constructor(source: ModelSerializer, objectIdWriter: ObjectIdWriter) : super(source, objectIdWriter) + + constructor(source: ModelSerializer, toIgnore: MutableSet) : super(source, toIgnore) + + constructor(source: ModelSerializer, objectIdWriter: ObjectIdWriter, filterId: Any?) : super( + source, + objectIdWriter, + filterId + ) + + override fun withObjectIdWriter(objectIdWriter: ObjectIdWriter): BeanSerializerBase { + return ModelSerializer(this, objectIdWriter) + } + + override fun withIgnorals(toIgnore: MutableSet): BeanSerializerBase { + return ModelSerializer(this, toIgnore) + } + + override fun asArraySerializer(): BeanSerializerBase { + throw UnsupportedOperationException("Unsupported array serializer for DtoModel.") + } + + override fun withFilterId(filterId: Any?): BeanSerializerBase { + return ModelSerializer(this, _objectIdWriter, filterId) + } + + override fun serialize(bean: Any, gen: JsonGenerator, provider: SerializerProvider) { + gen.currentValue = bean + + gen.writeStartObject() + serializeFields(bean, gen, provider) + gen.writeEndObject() + } + + override fun serializeFields(bean: Any, gen: JsonGenerator, provider: SerializerProvider?) { + serializeTypeInfo(bean, gen, provider) + super.serializeFields(bean, gen, provider) + } + + override fun serializeFieldsFiltered(bean: Any, gen: JsonGenerator, provider: SerializerProvider?) { + serializeTypeInfo(bean, gen, provider) + super.serializeFieldsFiltered(bean, gen, provider) + } + + private fun serializeTypeInfo(bean: Any, gen: JsonGenerator, provider: SerializerProvider?) { + gen.currentValue = bean as DtoMeta + val context = getParentContext(gen.outputContext) + + val outputType = (context.currentValue as? DtoMeta)?.`$outputType` ?: false + + if (outputType) { + gen.writeStringField("\$type", bean.`$type`.typeName) + } + } + + private fun getParentContext(context: JsonStreamContext): JsonStreamContext { + return context.parent?.let { + val root = getParentContext(it) + if (root.currentValue !is DtoMeta) { + it + } else { + root + } + } ?: context + } +} diff --git a/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/TypeDeserializer.kt b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/TypeDeserializer.kt new file mode 100644 index 00000000..e103260c --- /dev/null +++ b/lib/sisyphus-dto/src/main/kotlin/com/bybutter/sisyphus/dto/jackson/TypeDeserializer.kt @@ -0,0 +1,22 @@ +package com.bybutter.sisyphus.dto.jackson + +import com.bybutter.sisyphus.reflect.toType +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonSerializer +import com.fasterxml.jackson.databind.SerializerProvider +import java.lang.reflect.Type + +internal class TypeDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Type { + return p.text.toType() + } +} + +internal class TypeSerializer : JsonSerializer() { + override fun serialize(value: Type?, gen: JsonGenerator, serializers: SerializerProvider) { + gen.writeString(value?.typeName) + } +} diff --git a/lib/sisyphus-dto/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module b/lib/sisyphus-dto/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module new file mode 100644 index 00000000..0abcd3f2 --- /dev/null +++ b/lib/sisyphus-dto/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module @@ -0,0 +1 @@ +com.bybutter.dto.jackson.DtoModule \ No newline at end of file diff --git a/lib/sisyphus-dto/src/test/kotlin/com/bybutter/sisyphus/dto/DtoTest.kt b/lib/sisyphus-dto/src/test/kotlin/com/bybutter/sisyphus/dto/DtoTest.kt new file mode 100644 index 00000000..eae9463b --- /dev/null +++ b/lib/sisyphus-dto/src/test/kotlin/com/bybutter/sisyphus/dto/DtoTest.kt @@ -0,0 +1,238 @@ +package com.bybutter.sisyphus.dto + +import com.bybutter.sisyphus.reflect.uncheckedCast +import kotlin.math.roundToInt +import kotlin.reflect.KProperty +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +interface TestDto : DtoModel { + var value: T +} + +interface StringTest : TestDto { + override var value: String +} + +interface Test2Dto : TestDto + +class DtoTest { + interface NormalDto : DtoModel { + var stringValue: String + var numberValue: Float + } + + @Test + fun `normal dto`() { + DtoModel { + stringValue = "test" + numberValue = 123.456f + } + } + + @Test + fun `null value`() { + assertCauseThrows { + DtoModel { + stringValue = "test" + } + } + } + + interface NormalDtoWithNullableProperty : DtoModel { + var stringValue: String + @get:NullableProperty + var numberValue: Float + } + + @Test + fun `nullable property`() { + DtoModel { + stringValue = "test" + } + } + + @Test + fun `dto cast`() { + val dto = DtoModel { + stringValue = "test" + } + + assertCauseThrows { + dto.cast() + } + + val newDto = dto.cast { + numberValue = 123.456f + } + + Assertions.assertEquals(newDto.numberValue, dto.numberValue) + Assertions.assertEquals(newDto.uncheckedCast().`$modelMap`, dto.uncheckedCast().`$modelMap`) + } + + interface NormalDtoWithPropertyValidation : DtoModel { + class MustBiggerThan : PropertyValidator { + override fun verify( + proxy: ModelProxy, + value: Float, + params: Array, + property: KProperty + ): Exception? { + return if (value <= params[0].toFloat()) { + IllegalArgumentException("$property must be bigger than '${params[0]}'") + } else { + null + } + } + } + + @set:PropertyValidation(MustBiggerThan::class, ["2.0"]) + var numberValue: Float + + @set:PropertyValidation(MustBiggerThan::class, ["10.0"]) + var numberValue2: Float + } + + @Test + fun `property validation`() { + DtoModel { + numberValue = 5.0f + numberValue2 = 15.0f + } + + assertCauseThrows { + DtoModel { + numberValue = 0.0f + numberValue2 = 15.0f + } + } + + assertCauseThrows { + DtoModel { + numberValue = 5.0f + numberValue2 = 0.0f + } + } + } + + @DtoValidation(NormalDtoWithDtoValidation.MaxMustBiggerThanMin::class) + interface NormalDtoWithDtoValidation : DtoModel { + class MaxMustBiggerThanMin : DtoValidator { + override fun verify(value: NormalDtoWithDtoValidation, params: Array): Exception? { + if (value.min >= value.max) { + return IllegalArgumentException("max must be bigger than min.") + } + + return null + } + } + + var min: Float + + var max: Float + } + + @Test + fun `dto validation`() { + DtoModel { + min = 0.0f + max = 1.0f + } + + assertCauseThrows("max must be bigger than min.") { + DtoModel { + min = 0.0f + max = -1.0f + } + } + } + + interface NormalDtoWithHook : DtoModel { + class RoundingProperty : PropertyHookHandler { + override fun invoke( + target: Any, + value: String, + params: Array, + property: KProperty + ): String { + return value.toFloat().roundToInt().toString() + } + } + + @get:PropertyHook(RoundingProperty::class) + var numberWithRounding: String + + @set:PropertyHook(RoundingProperty::class) + var numberWithRounding2: String + } + + @Test + fun `dto hook`() { + val dto = DtoModel { + numberWithRounding = "1.4" + numberWithRounding2 = "2.5" + } + + Assertions.assertEquals("1", dto.numberWithRounding) + Assertions.assertEquals("3", dto.numberWithRounding2) + Assertions.assertEquals("1.4", dto.uncheckedCast().`$modelMap`["numberWithRounding"]) + Assertions.assertEquals("3", dto.uncheckedCast()["numberWithRounding2"]) + } + + interface NormalDtoWithDefaultValue : DtoModel { + @get:DefaultValue("string") + var value: String + + @get:DefaultValue("123") + var intValue: Int + } + + @Test + fun `dto with default value`() { + val dto = DtoModel() + + Assertions.assertEquals("string", dto.value) + Assertions.assertEquals(123, dto.intValue) + } +} + +inline fun assertCauseThrows(block: () -> Unit) { + try { + block() + } catch (e: Exception) { + var ex: Throwable? = e + while (ex != null) { + if (ex is T) { + assertThrows { + throw ex ?: return@assertThrows + } + return + } + + ex = ex.cause + } + + assertThrows {} + } +} + +inline fun assertCauseThrows(message: String, block: () -> Unit) { + try { + block() + } catch (e: Exception) { + var ex: Throwable? = e + while (ex != null) { + if (ex is T) { + assertThrows(message) { + throw ex ?: return@assertThrows + } + return + } + + ex = ex.cause + } + + assertThrows {} + } +} diff --git a/lib/sisyphus-grpc/build.gradle.kts b/lib/sisyphus-grpc/build.gradle.kts new file mode 100644 index 00000000..c722f254 --- /dev/null +++ b/lib/sisyphus-grpc/build.gradle.kts @@ -0,0 +1,36 @@ +lib + +plugins { + antlr + `java-library` + protobuf +} + +dependencies { + implementation(project(":lib:sisyphus-jackson")) + api(project(":lib:sisyphus-protobuf")) + api(Dependencies.Grpc.stub) + api(Dependencies.Kotlin.Coroutines.reactor) + api(Dependencies.Kotlin.Coroutines.guava) + api(Dependencies.Proto.base) + + implementation(project(":lib:sisyphus-common")) + implementation(Dependencies.Grpc.proto) + implementation(Dependencies.Kotlin.reflect) + + proto(Dependencies.Proto.grpcProto) + antlr(Dependencies.antlr4) +} + +protobuf { + packageMapping( + "google.api" to "com.bybutter.sisyphus.api", + "google.cloud.audit" to "com.bybutter.sisyphus.cloud.audit", + "google.geo.type" to "com.bybutter.sisyphus.geo.type", + "google.logging.type" to "com.bybutter.sisyphus.logging.type", + "google.longrunning" to "com.bybutter.sisyphus.longrunning", + "google.rpc" to "com.bybutter.sisyphus.rpc", + "google.rpc.context" to "com.bybutter.sisyphus.rpc.context", + "google.type" to "com.bybutter.sisyphus.type" + ) +} diff --git a/lib/sisyphus-grpc/src/main/antlr/com/bybutter/sisyphus/api/filtering/grammar/Filter.g4 b/lib/sisyphus-grpc/src/main/antlr/com/bybutter/sisyphus/api/filtering/grammar/Filter.g4 new file mode 100644 index 00000000..f1d8214f --- /dev/null +++ b/lib/sisyphus-grpc/src/main/antlr/com/bybutter/sisyphus/api/filtering/grammar/Filter.g4 @@ -0,0 +1,229 @@ +grammar Filter; + +@header { +package com.bybutter.sisyphus.api.filtering.grammar; +} + +filter + : e=expression? EOF + ; + +// Expressions may either be a conjunction (AND) of sequences or a simple +// sequence. +// +// Note, the AND is case-sensitive. +// +// Example: `a b AND c AND d` +// +// The expression `(a b) AND c AND d` is equivalent to the example. +expression + : seq+=sequence (op='AND' seq+=sequence)* + ; + +// Sequence is composed of one or more whitespace (WS) separated factors. +// +// A sequence expresses a logical relationship between 'factors' where +// the ranking of a filter result may be scored according to the number +// factors that match and other such criteria as the proximity of factors +// to each other within a document. +// +// When filters are used with exact match semantics rather than fuzzy +// match semantics, a sequence is equivalent to AND. +// +// Example: `New York Giants OR Yankees` +// +// The expression `New York (Giants OR Yankees)` is equivalent to the +// example. +sequence + : e+=factor+ + ; + +// Factors may either be a disjunction (OR) of terms or a simple term. +// +// Note, the OR is case-sensitive. +// +// Example: `a < 10 OR a >= 100` +factor + : e+=term (op='OR' e+=term)* + ; + +// Terms may either be unary or simple expressions. +// +// Unary expressions negate the simple expression, either mathematically `-` +// or logically `NOT`. The negation styles may be used interchangeably. +// +// Note, the `NOT` is case-sensitive and must be followed by at least one +// whitespace (WS). +// +// Examples: +// * logical not : `NOT (a OR b)` +// * alternative not : `-file:".java"` +// * negation : `-30` +term + : op=('NOT' | '-')? simple + ; + +// Simple expressions may either be a restriction or a nested (composite) +// expression. +simple + : restriction # RestrictionExpr + | composite # CompositeExpr + ; + +// Restrictions express a relationship between a comparable value and a +// single argument. When the restriction only specifies a comparable +// without an operator, this is a global restriction. +// +// Note, restrictions are not whitespace sensitive. +// +// Examples: +// * equality : `package=com.google` +// * inequality : `msg != 'hello'` +// * greater than : `1 > 0` +// * greater or equal : `2.5 >= 2.4` +// * less than : `yesterday < request.time` +// * less or equal : `experiment.rollout <= cohort(request.user)` +// * has : `map:key` +// * global : `prod` +// +// In addition to the global, equality, and ordering operators, filters +// also support the has (`:`) operator. The has operator is unique in +// that it can test for presence or value based on the proto3 type of +// the `comparable` value. The has operator is useful for validating the +// structure and contents of complex values. +restriction + : left=comparable (op=comparator right=arg)? + ; + +// Comparable may either be a member or function. +comparable + : function # FucntionExpr + | member # MemberExpr + ; + +// Member expressions are either value or DOT qualified field references. +// +// Example: expr.type_map.1.type +member + : value ('.' e+=field)* + ; + +// Function calls may use simple or qualified names with zero or more +// arguments. +// +// All functions declared within the list filter, apart from the special +// `arguments` function must be provided by the host service. +// +// Examples: +// * regex(m.key, '^.*prod.*$') +// * math.mem('30mb') +// +// Antipattern: simple and qualified function names may include keywords: +// NOT, AND, OR. It is not recommended that any of these names be used +// within functions exposed by a service that supports list filters. +function + : n+=name ('.' n+=name)* '(' argList? ')' + ; + +// Comparators supported by list filters. +comparator + : LESS_EQUALS + | LESS_THAN + | GREATER_EQUALS + | GREATER_THAN + | NOT_EQUALS + | EQUALS + | HAS + ; + +// Composite is a parenthesized expression, commonly used to group +// terms or clarify operator precedence. +// +// Example: `(msg.endsWith('world') AND retries < 10)` +composite + : '(' expression ')' + ; + +// Value may either be a TEXT or STRING. +// +// TEXT is a free-form set of characters without whitespace (WS) +// or . (DOT) within it. The text may represent a variable, string, +// number, boolean, or alternative literal value and must be handled +// in a manner consistent with the service's intention. +// +// STRING is a quoted string which may or may not contain a special +// wildcard `*` character at the beginning or end of the string to +// indicate a prefix or suffix-based search within a restriction. +value + : TEXT + | STRING + ; + +// Fields may be either a value or a keyword. +field + : value + | keyword + ; + +name + : TEXT + | keyword + ; + +argList + : e+=arg (',' e+=arg)* + ; + +arg + : comparable # ArgComparableExpr + | composite # ArgCompositeExpr + ; + +keyword + : NOT + | AND + | OR + ; + +// Lexer Rules +// =========== + +EQUALS : '='; +NOT_EQUALS : '!='; +IN: 'in'; +LESS_THAN : '<'; +LESS_EQUALS : '<='; +GREATER_EQUALS : '>='; +GREATER_THAN : '>'; + +LPAREN : '('; +RPAREN : ')'; +DOT : '.'; +COMMA : ','; +MINUS : '-'; +EXCLAM : '!'; +QUESTIONMARK : '?'; +HAS : ':'; +PLUS : '+'; +STAR : '*'; +SLASH : '/'; +PERCENT : '%'; +AND : 'AND'; +OR : 'OR'; +NOT : 'NOT'; + +fragment BACKSLASH : '\\'; +fragment LETTER : 'A'..'Z' | 'a'..'z' ; +fragment DIGIT : '0'..'9' ; + +WHITESPACE : ('\t' | ' ' | '\r' | '\n'| '\u000C')+ -> channel(HIDDEN) ; +COMMENT : '//' (~'\n')* -> channel(HIDDEN) ; + +STRING + : '"' ~('"'|'\n'|'\r')* '"' + | '\'' ~('\''|'\n'|'\r')* '\'' + | '"""' .*? '"""' + | '\'\'\'' .*? '\'\'\'' + ; + +TEXT : (LETTER | DIGIT | '_')+; \ No newline at end of file diff --git a/lib/sisyphus-grpc/src/main/antlr/com/bybutter/sisyphus/api/ordering/grammar/Order.g4 b/lib/sisyphus-grpc/src/main/antlr/com/bybutter/sisyphus/api/ordering/grammar/Order.g4 new file mode 100644 index 00000000..846047a5 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/antlr/com/bybutter/sisyphus/api/ordering/grammar/Order.g4 @@ -0,0 +1,40 @@ +grammar Order; + +@header { +package com.bybutter.sisyphus.api.ordering.grammar; +} + +// Grammar Rules +// ============= + +start + : expr? EOF + ; + +expr + : order ( ',' order )* + ; + +order + : field ('desc' | 'asc')? + ; + +field + : IDENTIFIER ( '.' IDENTIFIER )* + ; + +// Lexer Rules +// =========== + +DOT : '.'; +COMMA : ','; +DESC : 'desc'; +ASC : 'asc'; + +fragment LETTER : 'A'..'Z' | 'a'..'z' ; +fragment DIGIT : '0'..'9' ; + +WHITESPACE : ( '\t' | ' ' | '\r' | '\n'| '\u000C' )+ -> channel(HIDDEN) ; +COMMENT : '//' (~'\n')* -> channel(HIDDEN) ; + +IDENTIFIER : (LETTER | '_') ( LETTER | DIGIT | '_')*; \ No newline at end of file diff --git a/lib/sisyphus-grpc/src/main/antlr/com/bybutter/sisyphus/cel/grammar/Cel.g4 b/lib/sisyphus-grpc/src/main/antlr/com/bybutter/sisyphus/cel/grammar/Cel.g4 new file mode 100644 index 00000000..37c1f8f9 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/antlr/com/bybutter/sisyphus/cel/grammar/Cel.g4 @@ -0,0 +1,190 @@ +// Copyright 2018 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +grammar Cel; + +@header { +package com.bybutter.sisyphus.cel.grammar; +} + +// Grammar Rules +// ============= + +start + : e=expr EOF + ; + +expr + : e=conditionalOr (op='?' e1=conditionalOr ':' e2=expr)? + ; + +conditionalOr + : e=conditionalAnd (ops+='||' e1+=conditionalAnd)* + ; + +conditionalAnd + : e=relation (ops+='&&' e1+=relation)* + ; + +relation + : calc + | relation op=('<'|'<='|'>='|'>'|'=='|'!='|'in') relation + ; + +calc + : unary + | calc op=('*'|'/'|'%') calc + | calc op=('+'|'-') calc + ; + +unary + : member # MemberExpr + | (ops+='!')+ member # LogicalNot + | (ops+='-')+ member # Negate + ; + +member + : primary # PrimaryExpr + | member op='.' id=IDENTIFIER (open='(' args=exprList? ')')? # SelectOrCall + | member op='[' index=expr ']' # Index + | member op='{' entries=fieldInitializerList? '}' # CreateMessage + ; + +primary + : leadingDot='.'? id=IDENTIFIER (op='(' args=exprList? ')')? # IdentOrGlobalCall + | '(' e=expr ')' # Nested + | op='[' elems=exprList? ','? ']' # CreateList + | op='{' entries=mapInitializerList? '}' # CreateStruct + | literal # ConstantLiteral + ; + +exprList + : e+=expr (',' e+=expr)* + ; + +fieldInitializerList + : fields+=IDENTIFIER cols+=':' values+=expr (',' fields+=IDENTIFIER cols+=':' values+=expr)* + ; + +mapInitializerList + : keys+=expr cols+=':' values+=expr (',' keys+=expr cols+=':' values+=expr)* + ; + +literal + : sign=MINUS? tok=NUM_INT # Int + | tok=NUM_UINT # Uint + | sign=MINUS? tok=NUM_FLOAT # Double + | tok=STRING # String + | tok=BYTES # Bytes + | tok='true' # BoolTrue + | tok='false' # BoolFalse + | tok='null' # Null + ; + +// Lexer Rules +// =========== + +EQUALS : '=='; +NOT_EQUALS : '!='; +IN: 'in'; +LESS : '<'; +LESS_EQUALS : '<='; +GREATER_EQUALS : '>='; +GREATER : '>'; +LOGICAL_AND : '&&'; +LOGICAL_OR : '||'; + +LBRACKET : '['; +RPRACKET : ']'; +LBRACE : '{'; +RBRACE : '}'; +LPAREN : '('; +RPAREN : ')'; +DOT : '.'; +COMMA : ','; +MINUS : '-'; +EXCLAM : '!'; +QUESTIONMARK : '?'; +COLON : ':'; +PLUS : '+'; +STAR : '*'; +SLASH : '/'; +PERCENT : '%'; +TRUE : 'true'; +FALSE : 'false'; +NULL : 'null'; + +fragment BACKSLASH : '\\'; +fragment LETTER : 'A'..'Z' | 'a'..'z' ; +fragment DIGIT : '0'..'9' ; +fragment EXPONENT : ('e' | 'E') ( '+' | '-' )? DIGIT+ ; +fragment HEXDIGIT : ('0'..'9'|'a'..'f'|'A'..'F') ; +fragment RAW : 'r' | 'R'; + +fragment ESC_SEQ + : ESC_CHAR_SEQ + | ESC_BYTE_SEQ + | ESC_UNI_SEQ + | ESC_OCT_SEQ + ; + +fragment ESC_CHAR_SEQ + : BACKSLASH ('a'|'b'|'f'|'n'|'r'|'t'|'v'|'"'|'\''|'\\'|'?'|'`') + ; + +fragment ESC_OCT_SEQ + : BACKSLASH ('0'..'3') ('0'..'7') ('0'..'7') + ; + +fragment ESC_BYTE_SEQ + : BACKSLASH ( 'x' | 'X' ) HEXDIGIT HEXDIGIT + ; + +fragment ESC_UNI_SEQ + : BACKSLASH 'u' HEXDIGIT HEXDIGIT HEXDIGIT HEXDIGIT + | BACKSLASH 'U' HEXDIGIT HEXDIGIT HEXDIGIT HEXDIGIT HEXDIGIT HEXDIGIT HEXDIGIT HEXDIGIT + ; + +WHITESPACE : ( '\t' | ' ' | '\r' | '\n'| '\u000C' )+ -> channel(HIDDEN) ; +COMMENT : '//' (~'\n')* -> channel(HIDDEN) ; + +NUM_FLOAT + : ( DIGIT+ ('.' DIGIT+) EXPONENT? + | DIGIT+ EXPONENT + | '.' DIGIT+ EXPONENT? + ) + ; + +NUM_INT + : ( DIGIT+ | '0x' HEXDIGIT+ ); + +NUM_UINT + : DIGIT+ ( 'u' | 'U' ) + | '0x' HEXDIGIT+ ( 'u' | 'U' ) + ; + +STRING + : '"' (ESC_SEQ | ~('\\'|'"'|'\n'|'\r'))* '"' + | '\'' (ESC_SEQ | ~('\\'|'\''|'\n'|'\r'))* '\'' + | '"""' (ESC_SEQ | ~('\\'))*? '"""' + | '\'\'\'' (ESC_SEQ | ~('\\'))*? '\'\'\'' + | RAW '"' ~('"'|'\n'|'\r')* '"' + | RAW '\'' ~('\''|'\n'|'\r')* '\'' + | RAW '"""' .*? '"""' + | RAW '\'\'\'' .*? '\'\'\'' + ; + +BYTES : ('b' | 'B') STRING; + +IDENTIFIER : (LETTER | '_') ( LETTER | DIGIT | '_')*; \ No newline at end of file diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/filtering/DynamicCaster.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/filtering/DynamicCaster.kt new file mode 100644 index 00000000..fb5091f8 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/filtering/DynamicCaster.kt @@ -0,0 +1,88 @@ +package com.bybutter.sisyphus.api.filtering + +import com.bybutter.sisyphus.protobuf.EnumSupport +import com.bybutter.sisyphus.protobuf.ProtoEnum +import com.bybutter.sisyphus.protobuf.primitives.Duration +import com.bybutter.sisyphus.protobuf.primitives.Timestamp +import com.bybutter.sisyphus.protobuf.primitives.invoke +import com.bybutter.sisyphus.protobuf.primitives.string +import kotlin.reflect.full.companionObjectInstance + +object DynamicCaster { + fun Any?.cast(target: Class<*>): Any? { + if (target.isInstance(this)) return this + + return when (this) { + is Number -> cast(this, target) + is String -> cast(this, target) + is Boolean -> cast(this, target) + is Timestamp -> cast(this, target) + is Duration -> cast(this, target) + else -> null + } + } + + fun cast(value: Number, target: Class<*>): Any? { + return when (target) { + Int::class.java -> value.toInt() + UInt::class.java -> value.toInt().toUInt() + Long::class.java -> value.toLong() + ULong::class.java -> value.toLong().toULong() + Double::class.java -> value.toDouble() + Float::class.java -> value.toFloat() + String::class.java -> value.toString() + Boolean::class.java -> value.toInt() != 0 + else -> null + } + } + + fun cast(value: Boolean, target: Class<*>): Any? { + return when (target) { + Int::class.java -> 1 + UInt::class.java -> 1U + Long::class.java -> 1L + ULong::class.java -> 1UL + Double::class.java -> 1.0 + Float::class.java -> 1.0f + String::class.java -> value.toString() + else -> null + } + } + + fun cast(value: String, target: Class<*>): Any? { + return when (target) { + Int::class.java -> value.toIntOrNull() + UInt::class.java -> value.toUIntOrNull() + Long::class.java -> value.toLongOrNull() + ULong::class.java -> value.toULongOrNull() + Double::class.java -> value.toDoubleOrNull() + Float::class.java -> value.toFloatOrNull() + Boolean::class.java -> value.toBoolean() + Number::class.java -> value.toDoubleOrNull() + Timestamp::class.java -> Timestamp(value) + Duration::class.java -> Duration(value) + else -> { + if (ProtoEnum::class.java.isAssignableFrom(target)) { + val support = target.kotlin.companionObjectInstance as? EnumSupport<*> + ?: return null + return support.fromProto(value) + } + return null + } + } + } + + fun cast(value: Timestamp, target: Class<*>): Any? { + return when (target) { + String::class.java -> value.string() + else -> return null + } + } + + fun cast(value: Duration, target: Class<*>): Any? { + return when (target) { + String::class.java -> value.string() + else -> return null + } + } +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/filtering/DynamicOperator.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/filtering/DynamicOperator.kt new file mode 100644 index 00000000..db9a7682 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/filtering/DynamicOperator.kt @@ -0,0 +1,83 @@ +package com.bybutter.sisyphus.api.filtering + +import com.bybutter.sisyphus.protobuf.primitives.Duration +import com.bybutter.sisyphus.protobuf.primitives.Timestamp +import com.bybutter.sisyphus.protobuf.primitives.compareTo +import com.bybutter.sisyphus.protobuf.primitives.tryParse +import com.bybutter.sisyphus.string.PathMatcher + +object DynamicOperator : Comparator { + override fun compare(o1: String?, o2: String?): Int { + val left = if (o1 == "null") null else o1 + val right = if (o2 == "null") null else o2 + + val leftDouble = left?.toDoubleOrNull() + val rightDouble = right?.toDoubleOrNull() + + if (leftDouble != null && rightDouble != null) return leftDouble.compareTo(rightDouble) + + val leftBoolean = when (left) { + "true" -> true + "false" -> false + null -> false + else -> null + } + val rightBoolean = when (right) { + "true" -> true + "false" -> false + null -> false + else -> null + } + if (leftBoolean != null && rightBoolean != null) return leftBoolean.compareTo(rightBoolean) + + val leftDuration = left?.let { Duration.tryParse(it) } + val rightDuration = right?.let { Duration.tryParse(it) } + if (leftDuration != null && rightDuration != null) return leftDuration.compareTo(rightDuration) + + val leftTimestamp = left?.let { Timestamp.tryParse(it) } + val rightTimestamp = right?.let { Timestamp.tryParse(it) } + if (leftTimestamp != null && rightTimestamp != null) return leftTimestamp.compareTo(rightTimestamp) + + if (left == null && right == null) return 0 + if (left == null && right != null) return -1 + if (left != null && right == null) return 1 + + return left!!.compareTo(right!!) + } + + fun equals(o1: String?, o2: String?): Boolean { + val left = if (o1 == "null") null else o1 + val right = if (o2 == "null") null else o2 + + if (left == null && right == null) return true + if (left == null || right == null) return false + + val leftDouble = left.toDoubleOrNull() + val rightDouble = right.toDoubleOrNull() + if (leftDouble != null && rightDouble != null) return leftDouble == rightDouble + + val leftBoolean = when (left) { + "true" -> true + "false" -> false + null -> false + else -> null + } + val rightBoolean = when (right) { + "true" -> true + "false" -> false + null -> false + else -> null + } + if (leftBoolean != null && rightBoolean != null) return leftBoolean == rightBoolean + + val leftDuration = Duration.tryParse(left) + val rightDuration = Duration.tryParse(right) + if (leftDuration != null && rightDuration != null) return leftDuration.compareTo(rightDuration) == 0 + + val leftTimestamp = Timestamp.tryParse(left) + val rightTimestamp = Timestamp.tryParse(right) + if (leftTimestamp != null && rightTimestamp != null) return leftTimestamp.compareTo(rightTimestamp) == 0 + + return PathMatcher.match(right, left) + } +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/filtering/FilterFunction.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/filtering/FilterFunction.kt new file mode 100644 index 00000000..f57e51d1 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/filtering/FilterFunction.kt @@ -0,0 +1,3 @@ +package com.bybutter.sisyphus.api.filtering + +interface FilterFunction : (List) -> Any? diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/filtering/FilterRuntime.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/filtering/FilterRuntime.kt new file mode 100644 index 00000000..a9644906 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/filtering/FilterRuntime.kt @@ -0,0 +1,15 @@ +package com.bybutter.sisyphus.api.filtering + +class FilterRuntime(vararg functions: Pair) { + private val functions = mutableMapOf(*functions) + + fun registerFunction(name: String, function: FilterFunction) { + functions[name] = function + } + + fun invoke(function: String, arguments: List): Any? { + val func = functions[function] + ?: throw NoSuchMethodException("Method '$function()' not registered in filter expression runtime.") + return func.invoke(arguments) + } +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/filtering/MessageFilter.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/filtering/MessageFilter.kt new file mode 100644 index 00000000..f62ef20f --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/filtering/MessageFilter.kt @@ -0,0 +1,293 @@ +package com.bybutter.sisyphus.api.filtering + +import com.bybutter.sisyphus.api.filtering.grammar.FilterLexer +import com.bybutter.sisyphus.api.filtering.grammar.FilterParser +import com.bybutter.sisyphus.protobuf.CustomProtoType +import com.bybutter.sisyphus.protobuf.Message +import com.bybutter.sisyphus.protobuf.ProtoEnum +import com.bybutter.sisyphus.protobuf.primitives.BoolValue +import com.bybutter.sisyphus.protobuf.primitives.BytesValue +import com.bybutter.sisyphus.protobuf.primitives.DoubleValue +import com.bybutter.sisyphus.protobuf.primitives.Duration +import com.bybutter.sisyphus.protobuf.primitives.FloatValue +import com.bybutter.sisyphus.protobuf.primitives.Int32Value +import com.bybutter.sisyphus.protobuf.primitives.Int64Value +import com.bybutter.sisyphus.protobuf.primitives.ListValue +import com.bybutter.sisyphus.protobuf.primitives.NullValue +import com.bybutter.sisyphus.protobuf.primitives.StringValue +import com.bybutter.sisyphus.protobuf.primitives.Struct +import com.bybutter.sisyphus.protobuf.primitives.Timestamp +import com.bybutter.sisyphus.protobuf.primitives.UInt32Value +import com.bybutter.sisyphus.protobuf.primitives.UInt64Value +import com.bybutter.sisyphus.protobuf.primitives.Value +import com.bybutter.sisyphus.protobuf.primitives.string +import com.bybutter.sisyphus.security.base64 +import org.antlr.v4.runtime.CharStreams +import org.antlr.v4.runtime.CommonTokenStream + +class MessageFilter(filter: String, val runtime: FilterRuntime = FilterRuntime()) : (Message<*, *>) -> Boolean { + private val filter: FilterParser.FilterContext = parse(filter) + + fun filter(message: Message<*, *>): Boolean { + return invoke(message) + } + + override fun invoke(message: Message<*, *>): Boolean { + val expr = filter.e ?: return true + return visit(message, expr) + } + + private fun visit(message: Message<*, *>, expr: FilterParser.ExpressionContext): Boolean { + for (seq in expr.seq) { + if (!visit(message, seq)) { + return false + } + } + return true + } + + private fun visit(message: Message<*, *>, seq: FilterParser.SequenceContext): Boolean { + for (e in seq.e) { + if (!visit(message, e)) { + return false + } + } + return true + } + + private fun visit(message: Message<*, *>, fac: FilterParser.FactorContext): Boolean { + for (e in fac.e) { + if (visit(message, e)) { + return true + } + } + return false + } + + private fun visit(message: Message<*, *>, term: FilterParser.TermContext): Boolean { + var value = visit(message, term.simple()) + value = when (term.op?.text) { + "NOT" -> { + when (value) { + is Boolean -> !value + is String -> !value.toBoolean() + null -> true + else -> false + } + } + "-" -> { + when (value) { + is Boolean -> !value + is Int -> -value + is UInt -> (-value.toLong()).toInt() + is Long -> -value + is ULong -> -value.toLong() + is String -> value.toDoubleOrNull()?.let { -it } ?: true + null -> true + else -> 0 + } + } + null -> value + else -> throw IllegalArgumentException("Unsupported term operator '${term.op?.text}'.") + } + + return when (value) { + is Boolean -> value + is Number -> value.toDouble() != 0.0 + is String -> value.toBoolean() + null -> false + else -> true + } + } + + private fun visit(message: Message<*, *>, simple: FilterParser.SimpleContext): Any? { + return when (simple) { + is FilterParser.RestrictionExprContext -> { + visit(message, simple.restriction()) + } + is FilterParser.CompositeExprContext -> { + visit(message, simple.composite()) + } + else -> throw UnsupportedOperationException("Unsupported simple expression '${simple.text}'.") + } + } + + private fun visit(message: Message<*, *>, rest: FilterParser.RestrictionContext): Any? { + val left = visit(message, rest.left) + return if (rest.op != null) { + when (rest.op.text) { + "<=", "<", ">", ">=" -> { + val right = visit(message, rest.right) + val result = DynamicOperator.compare(left?.toString(), right?.toString()) + when (rest.op.text) { + "<=" -> result <= 0 + "<" -> result < 0 + ">=" -> result >= 0 + ">" -> result > 0 + else -> TODO() + } + } + "=" -> { + val right = visit(message, rest.right) + DynamicOperator.equals(left?.toString(), right?.toString()) + } + "!=" -> { + val right = visit(message, rest.right) + !DynamicOperator.equals(left?.toString(), right?.toString()) + } + ":" -> { + val right = visit(message, rest.right)?.toString() + if (right == "*") return left != null + + when (left) { + is Message<*, *> -> { + right ?: return false + left.has(right) + } + is List<*> -> left.any { + DynamicOperator.equals(it?.toString(), right) + } + is Map<*, *> -> { + right ?: return false + left.containsKey(right) + } + else -> DynamicOperator.equals(left?.toString(), right) + } + } + else -> TODO() + } + } else { + return left + } + } + + private fun visit(message: Message<*, *>, com: FilterParser.ComparableContext): Any? { + return when (com) { + is FilterParser.FucntionExprContext -> visit(message, com.function()) + is FilterParser.MemberExprContext -> visit(message, com.member()) + else -> throw UnsupportedOperationException("Unsupported comparable expression '${com.text}'.") + } + } + + private fun visit(message: Message<*, *>, com: FilterParser.CompositeContext): Any? { + return visit(message, com.expression()) + } + + private fun visit(message: Message<*, *>, com: FilterParser.FunctionContext): Any? { + val function = com.n.joinToString(".") { it.text } + return runtime.invoke(function, com.argList()?.e?.map { visit(message, it) } ?: listOf()) + } + + private fun visit(message: Message<*, *>, arg: FilterParser.ArgContext): Any? { + return when (arg) { + is FilterParser.ArgComparableExprContext -> visit(message, arg.comparable()) + is FilterParser.ArgCompositeExprContext -> visit(message, arg.composite()) + else -> throw UnsupportedOperationException("Unsupported arg expression '${arg.text}'.") + } + } + + private fun visit(message: Message<*, *>, arg: FilterParser.MemberContext): Any? { + val memberStart = visit(message, arg.value()) + return if (memberStart != null && message.support().fieldInfo(memberStart) != null) { + var value = message.get(memberStart) + + for (fieldContext in arg.e) { + val fieldName = visit(message, fieldContext) + + when (value) { + is Message<*, *> -> { + value = value.get(fieldName).protoNormalizing() ?: return null + } + is List<*> -> { + val int = fieldName.toIntOrNull() ?: return null + if (int >= value.size) return null + value = value[int] + } + is Map<*, *> -> { + if (!value.containsKey(fieldName)) return null + value = value[fieldName] + } + else -> return null + } + } + + value + } else { + val value = mutableListOf(memberStart) + value += arg.e.map { visit(message, it) } + value.joinToString(".") + } + } + + private fun visit(message: Message<*, *>, field: FilterParser.FieldContext): String { + return field.value()?.let { visit(message, it) } ?: field.text + } + + private fun Any?.protoNormalizing(): Any? { + return when (this) { + is ByteArray -> this.base64() + is ListValue -> this.values.map { it.protoNormalizing() } + is DoubleValue -> this.value.toString() + is FloatValue -> this.value.toString() + is Int64Value -> this.value.toString() + is UInt64Value -> this.value.toString() + is Int32Value -> this.value.toString() + is UInt32Value -> this.value.toString() + is BoolValue -> this.value.toString() + is StringValue -> this.value + is BytesValue -> this.value.base64() + is Duration -> string() + is Timestamp -> string() + is NullValue -> null + is Struct -> this.fields.mapValues { it.value.protoNormalizing() } + is Value -> when (val kind = this.kind) { + is Value.Kind.BoolValue -> kind.value.toString() + is Value.Kind.ListValue -> kind.value.protoNormalizing() + is Value.Kind.NullValue -> null + is Value.Kind.NumberValue -> kind.value.toString() + is Value.Kind.StringValue -> kind.value + is Value.Kind.StructValue -> kind.value.protoNormalizing() + null -> null + else -> throw IllegalStateException("Illegal proto value type '${kind.javaClass}'.") + } + is ProtoEnum -> this.proto + is List<*> -> this.map { it.protoNormalizing() } + is Map<*, *> -> this.mapValues { it.value.protoNormalizing() } + is CustomProtoType<*> -> this.raw().protoNormalizing() + null -> null + is Int, is UInt, is Long, is ULong, is Float, is Double, is Boolean -> this.toString() + is String, is Message<*, *> -> this + else -> throw IllegalStateException("Illegal proto data type '${this.javaClass}'.") + } + } + + private fun visit(message: Message<*, *>, value: FilterParser.ValueContext): String? { + if (value.STRING() != null) { + return string(value.text) + } + + if (value.TEXT() != null) { + if (value.text == "null") return null + return value.text + } + + TODO() + } + + private fun string(data: String): String { + return when { + data.startsWith("\"\"\"") -> data.substring(3, data.length - 3) + data.startsWith("\"") -> data.substring(1, data.length - 1) + data.startsWith("'") -> data.substring(1, data.length - 1) + else -> throw IllegalStateException("Wrong string token '$data'.") + } + } + + companion object { + fun parse(filter: String): FilterParser.FilterContext { + val lexer = FilterLexer(CharStreams.fromString(filter)) + val parser = FilterParser(CommonTokenStream(lexer)) + return parser.filter() + } + } +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/paging/PagingExtension.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/paging/PagingExtension.kt new file mode 100644 index 00000000..156c191c --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/paging/PagingExtension.kt @@ -0,0 +1,48 @@ +package com.bybutter.sisyphus.api.paging + +import com.bybutter.sisyphus.security.base64UrlSafe +import com.bybutter.sisyphus.security.base64UrlSafeDecode + +operator fun NameAnchorPaging.Companion.invoke(pagingToken: String): NameAnchorPaging? { + if (pagingToken.isEmpty()) { + return null + } + + return NameAnchorPaging.parse(pagingToken.base64UrlSafeDecode()) +} + +fun NameAnchorPaging.toToken(): String { + return this.toProto().base64UrlSafe() +} + +fun NameAnchorPaging?.nextPage(name: String?, size: Int, pageSize: Int): String? { + if (name == null || size == 0 || size < pageSize) { + return null + } + + return NameAnchorPaging { + this.name = name + }.toToken() +} + +operator fun OffsetPaging.Companion.invoke(pagingToken: String): OffsetPaging? { + if (pagingToken.isEmpty()) { + return null + } + + return OffsetPaging.parse(pagingToken.base64UrlSafeDecode()) +} + +fun OffsetPaging.toToken(): String { + return this.toProto().base64UrlSafe() +} + +fun OffsetPaging?.nextPage(size: Int, pageSize: Int): String? { + if (size < pageSize) { + return null + } + + return OffsetPaging { + this.offset = this@nextPage?.offset?.plus(size) ?: size + }.toToken() +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/PathTemplate.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/PathTemplate.kt new file mode 100644 index 00000000..9fd9e52a --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/PathTemplate.kt @@ -0,0 +1,893 @@ +package com.bybutter.sisyphus.api.resource + +import java.io.UnsupportedEncodingException +import java.net.URLDecoder +import java.net.URLEncoder + +/** + * Represents a path template. + * + * + * + * Templates use the syntax of the API platform; see the protobuf of HttpRule for details. A + * template consists of a sequence of literals, wildcards, and variable bindings, where each binding + * can have a sub-path. A string representation can be parsed into an instance of + * [PathTemplate], which can then be used to perform matching and instantiation. + * + * + * + * Matching and instantiation deals with unescaping and escaping using URL encoding rules. For + * example, if a template variable for a single segment is instantiated with a string like + * `"a/b"`, the slash will be escaped to `"%2f"`. (Note that slash will not be escaped + * for a multiple-segment variable, but other characters will). The literals in the template itself + * are *not* escaped automatically, and must be already URL encoded. + * + * + * + * Here is an example for a template using simple variables: + * + *
+ * PathTemplate template = PathTemplate.create("v1/shelves/{shelf}/books/{book}");
+ * assert template.matches("v2/shelves") == false;
+ * Map<String, String> values = template.match("v1/shelves/s1/books/b1");
+ * Map<String, String> expectedValues = new HashMap<>();
+ * expectedValues.put("shelf", "s1");
+ * expectedValues.put("book", "b1");
+ * assert values.equals(expectedValues);
+ * assert template.instantiate(values).equals("v1/shelves/s1/books/b1");
* + * + * Templates can use variables which match sub-paths. Example: + * + *
+ * PathTemplate template = PathTemplate.create("v1/{name=shelves/*/books/*}"};
+ * assert template.match("v1/shelves/books/b1") == null;
+ * Map<String, String> expectedValues = new HashMap<>();
+ * expectedValues.put("name", "shelves/s1/books/b1");
+ * assert template.match("v1/shelves/s1/books/b1").equals(expectedValues);
+
* + * + * Path templates can also be used with only wildcards. Each wildcard is associated with an implicit + * variable `$n`, where n is the zero-based position of the wildcard. Example: + * + *
+ * PathTemplate template = PathTemplate.create("shelves/*/books/*"};
+ * assert template.match("shelves/books/b1") == null;
+ * Map<String, String> values = template.match("v1/shelves/s1/books/b1");
+ * Map<String, String> expectedValues = new HashMap<>();
+ * expectedValues.put("$0", s1");
+ * expectedValues.put("$1", "b1");
+ * assert values.equals(expectedValues);
+
* + * + * Paths input to matching can use URL relative syntax to indicate a host name by prefixing the host + * name, as in `//somewhere.io/some/path`. The host name is matched into the special variable + * [.HOSTNAME_VAR]. Patterns are agnostic about host names, and the same pattern can be used + * for URL relative syntax and simple path syntax: + * + *
+ * PathTemplate template = PathTemplate.create("shelves/*"};
+ * Map<String, String> expectedValues = new HashMap<>();
+ * expectedValues.put(PathTemplate.HOSTNAME_VAR, "somewhere.io");
+ * expectedValues.put("$0", s1");
+ * assert template.match("//somewhere.io/shelves/s1").equals(expectedValues);
+ * expectedValues.clear();
+ * expectedValues.put("$0", s1");
+ * assert template.match("shelves/s1").equals(expectedValues);
+
* + * + * For the representation of a *resource name* see [TemplatedResourceName], which is + * based on path templates. + */ +class PathTemplate private constructor( + segments: Iterable, // Control use of URL encoding + private val urlEncoding: Boolean +) { + + // List of segments of this template. + private val segments: List + + // Map from variable names to bindings in the template. + private val bindings: Map + + init { + this.segments = segments.toList() + if (this.segments.isEmpty()) { + throw ValidationException("template cannot be empty.") + } + val bindings = mutableMapOf() + for (seg in this.segments) { + if (seg.kind == SegmentKind.BINDING) { + if (bindings.containsKey(seg.value)) { + throw ValidationException("Duplicate binding '%s'", seg.value) + } + bindings[seg.value] = seg + } + } + this.bindings = bindings.toMap() + } + + /** + * Returns the set of variable names used in the template. + */ + fun vars(): Set { + return bindings.keys + } + + /** + * Returns a template for the parent of this template. + * + * @throws ValidationException if the template has no parent. + */ + fun parentTemplate(): PathTemplate { + var i = segments.size + val seg = segments[--i] + if (seg.kind == SegmentKind.END_BINDING) { + while (i > 0 && segments[--i].kind != SegmentKind.BINDING) { + } + } + if (i == 0) { + throw ValidationException("template does not have a parent") + } + return PathTemplate(segments.subList(0, i), urlEncoding) + } + + /** + * Returns a template where all variable bindings have been replaced by wildcards, but which is + * equivalent regards matching to this one. + */ + fun withoutVars(): PathTemplate { + val result = buildString { + val iterator = segments.listIterator() + var start = true + while (iterator.hasNext()) { + val seg = iterator.next() + when (seg.kind) { + SegmentKind.BINDING, SegmentKind.END_BINDING -> { + } + else -> { + if (!start) { + append(seg.separator()) + } else { + start = false + } + append(seg.value) + } + } + } + } + return create(result, urlEncoding) + } + + /** + * Returns a path template for the sub-path of the given variable. Example: + * + *
+     * PathTemplate template = PathTemplate.create("v1/{name=shelves/*/books/*}");
+     * assert template.subTemplate("name").toString().equals("shelves/*/books/*");
+    
* + * + * The returned template will never have named variables, but only wildcards, which are dealt with + * in matching and instantiation using '$n'-variables. See the documentation of + * [.match] and [.instantiate], respectively. + * + * + * + * For a variable which has no sub-path, this returns a path template with a single wildcard + * ('*'). + * + * @throws ValidationException if the variable does not exist in the template. + */ + fun subTemplate(varName: String): PathTemplate { + val sub = mutableListOf() + var inBinding = false + for (seg in segments) { + if (seg.kind == SegmentKind.BINDING && seg.value == varName) { + inBinding = true + } else if (inBinding) { + if (seg.kind == SegmentKind.END_BINDING) { + return create(toSyntax(sub, true), urlEncoding) + } else { + sub.add(seg) + } + } + } + throw ValidationException( + String.format("Variable '%s' is undefined in template '%s'", varName, this.toRawString())) + } + + /** + * Returns true of this template ends with a literal. + */ + fun endsWithLiteral(): Boolean { + return segments[segments.size - 1].kind == SegmentKind.LITERAL + } + + /** + * Returns true of this template ends with a custom verb. + */ + fun endsWithCustomVerb(): Boolean { + return segments[segments.size - 1].kind == SegmentKind.CUSTOM_VERB + } + + /** + * Creates a resource name from this template and a path. + * + * @throws ValidationException if the path does not match the template. + */ + fun parse(path: String): TemplatedResourceName { + return TemplatedResourceName.create(this, path) + } + + /** + * Returns the name of a singleton variable used by this template. If the template does not + * contain a single variable, returns null. + */ + fun singleVar(): String? { + if (bindings.size == 1) { + return bindings.entries.iterator().next().key + } + return null + } + + /** + * Throws a ValidationException if the template doesn't match the path. The exceptionMessagePrefix + * parameter will be prepended to the ValidationException message. + */ + fun validate(path: String, exceptionMessagePrefix: String) { + if (!matches(path)) { + throw ValidationException( + String.format( + "%s: Parameter \"%s\" must be in the form \"%s\"", + exceptionMessagePrefix, + path, + this.toString())) + } + } + + /** + * Matches the path, returning a map from variable names to matched values. All matched values + * will be properly unescaped using URL encoding rules. If the path does not match the template, + * throws a ValidationException. The exceptionMessagePrefix parameter will be prepended to the + * ValidationException message. + * + * + * + * If the path starts with '//', the first segment will be interpreted as a host name and stored + * in the variable [HOSTNAME_VAR]. + * + * + * + * See the [PathTemplate] class documentation for examples. + * + * + * + * For free wildcards in the template, the matching process creates variables named '$n', where + * 'n' is the wildcard's position in the template (starting at n=0). For example: + * + *
+     * PathTemplate template = PathTemplate.create("shelves/*/books/*");
+     * Map<String, String> expectedValues = new HashMap<>();
+     * expectedValues.put("$0", "s1");
+     * expectedValues.put("$1", "b1");
+     * assert template.validatedMatch("shelves/s1/books/b2", "User exception string")
+     * .equals(expectedValues);
+     * expectedValues.clear();
+     * expectedValues.put(HOSTNAME_VAR, "somewhere.io");
+     * expectedValues.put("$0", "s1");
+     * expectedValues.put("$1", "b1");
+     * assert template.validatedMatch("//somewhere.io/shelves/s1/books/b2", "User exception string")
+     * .equals(expectedValues);
+    
* + * + * All matched values will be properly unescaped using URL encoding rules (so long as URL encoding + * has not been disabled by the [.createWithoutUrlEncoding] method). + */ + fun validatedMatch(path: String, exceptionMessagePrefix: String): Map { + return match(path) ?: throw ValidationException( + String.format( + "%s: Parameter \"%s\" must be in the form \"%s\"", + exceptionMessagePrefix, + path, + toString())) + } + + /** + * Returns true if the template matches the path. + */ + fun matches(path: String): Boolean { + return match(path) != null + } + + /** + * Matches the path, returning a map from variable names to matched values. All matched values + * will be properly unescaped using URL encoding rules. If the path does not match the template, + * null is returned. + * + * + * + * If the path starts with '//', the first segment will be interpreted as a host name and stored + * in the variable [.HOSTNAME_VAR]. + * + * + * + * See the [PathTemplate] class documentation for examples. + * + * + * + * For free wildcards in the template, the matching process creates variables named '$n', where + * 'n' is the wildcard's position in the template (starting at n=0). For example: + * + *
+     * PathTemplate template = PathTemplate.create("shelves/*/books/*");
+     * Map<String, String> expectedValues = new HashMap<>();
+     * expectedValues.put("$0", "s1");
+     * expectedValues.put("$1", "b1");
+     * assert template.match("shelves/s1/books/b2").equals(expectedValues);
+     * expectedValues.clear();
+     * expectedValues.put(HOSTNAME_VAR, "somewhere.io");
+     * expectedValues.put("$0", "s1");
+     * expectedValues.put("$1", "b1");
+     * assert template.match("//somewhere.io/shelves/s1/books/b2").equals(expectedValues);
+     * 
* + * + * All matched values will be properly unescaped using URL encoding rules (so long as URL encoding + * has not been disabled by the [.createWithoutUrlEncoding] method). + */ + fun match(path: String): Map? { + var path = path + // Quick check for trailing custom verb. + val last = segments[segments.size - 1] + if (last.kind == SegmentKind.CUSTOM_VERB) { + val matcher = CUSTOM_VERB_PATTERN.matcher(path) + if (!matcher.find() || decodeUrl(matcher.group(1)) != last.value) { + return null + } + path = path.substring(0, matcher.start(0)) + } + + val matcher = HOSTNAME_PATTERN.matcher(path) + val withHostName = matcher.find() + if (withHostName) { + path = matcher.replaceFirst("") + } + val input = path.split('/').map { it.trim() } + var inPos = 0 + val values = mutableMapOf() + if (withHostName) { + if (input.isEmpty()) { + return null + } + values[HOSTNAME_VAR] = input[inPos++] + } + if (withHostName) { + inPos = alignInputToAlignableSegment(input, inPos, segments[0]) + } + if (!match(input, inPos, segments, 0, values)) { + return null + } + return values.toMap() + } + + // Aligns input to start of literal value of literal or binding segment if input contains hostname. + private fun alignInputToAlignableSegment(input: List, inPos: Int, segment: Segment): Int { + return when (segment.kind) { + SegmentKind.BINDING -> inPos + SegmentKind.LITERAL -> alignInputPositionToLiteral(input, inPos, segment.value) + else -> inPos + } + } + + // Aligns input to start of literal value if input contains hostname. + private fun alignInputPositionToLiteral(input: List, inPos: Int, literalSegmentValue: String): Int { + var inPos = inPos + while (inPos < input.size) { + if (literalSegmentValue == input[inPos]) { + return inPos + } + inPos++ + } + return inPos + } + + // Tries to match the input based on the segments at given positions. Returns a boolean +// indicating whether the match was successful. + private fun match( + input: List, + inPos: Int, + segments: List, + segPos: Int, + values: MutableMap + ): Boolean { + var inPos = inPos + var segPos = segPos + var currentVar: String? = null + while (segPos < segments.size) { + val seg = segments[segPos++] + when (seg.kind) { + SegmentKind.END_BINDING -> { + // End current variable binding scope. + currentVar = null + } + SegmentKind.BINDING -> { + // Start variable binding scope. + currentVar = seg.value + } + SegmentKind.CUSTOM_VERB -> { + } + SegmentKind.LITERAL, SegmentKind.WILDCARD -> { + if (inPos >= input.size) { + // End of input + return false + } + // Check literal match. + val next = decodeUrl(input[inPos++]) + if (seg.kind == SegmentKind.LITERAL) { + if (seg.value != next) { + // Literal does not match. + return false + } + } + if (currentVar != null) { + // Create or extend current match + values[currentVar] = concatCaptures(values[currentVar], next) + } + } + SegmentKind.PATH_WILDCARD -> { + // Compute the number of additional input the ** can consume. This + // is possible because we restrict patterns to have only one **. + var segsToMatch = 0 + for (i in segPos until segments.size) { + when (segments[i].kind) { + SegmentKind.BINDING, SegmentKind.END_BINDING -> { + } + else -> segsToMatch++ + } + } + var available = (input.size - inPos) - segsToMatch + // If this segment is empty, make sure it is still captured. + if (available == 0 && !values.containsKey(currentVar)) { + values[currentVar!!] = "" + } + while (available-- > 0) { + values[currentVar!!] = concatCaptures(values[currentVar], decodeUrl(input[inPos++])) + } + } + } + // This is the final segment, and this check should have already been performed by the + // caller. The matching value is no longer present in the input. + } + return inPos == input.size + } + + /** + * Instantiate the template based on the given variable assignment. Performs proper URL escaping + * of variable assignments. + * + * + * + * Note that free wildcards in the template must have bindings of '$n' variables, where 'n' is the + * position of the wildcard (starting at 0). See the documentation of [.match] for + * details. + * + * @throws ValidationException if a variable occurs in the template without a binding. + */ + fun instantiate(values: Map): String { + return instantiate(values, false) + } + + /** + * Shortcut for [.instantiate] with a vararg parameter for keys and values. + */ + fun instantiate(vararg keysAndValues: String): String { + val builder = mutableMapOf() + var i = 0 + while (i < keysAndValues.size) { + builder[keysAndValues[i]] = keysAndValues[i + 1] + i += 2 + } + return instantiate(builder) + } + + /** + * Same like [.instantiate] but allows for unbound variables, which are substituted + * using their original syntax. Example: + * + *
+     * PathTemplate template = PathTemplate.create("v1/shelves/{shelf}/books/{book}");
+     * Map<String, String> partialMap = new HashMap<>();
+     * partialMap.put("shelf", "s1");
+     * assert template.instantiatePartial(partialMap).equals("v1/shelves/s1/books/{book}");
+    
* + * + * The result of this call can be used to create a new template. + */ + fun instantiatePartial(values: Map): String { + return instantiate(values, true) + } + + private fun instantiate(values: Map, allowPartial: Boolean): String { + val result = StringBuilder() + if (values.containsKey(HOSTNAME_VAR)) { + result.append("//") + result.append(values[HOSTNAME_VAR]) + result.append('/') + } + var continueLast = true // Whether to not append separator + var skip = false // Whether we are substituting a binding and segments shall be skipped. + val iterator = segments.listIterator() + while (iterator.hasNext()) { + val seg = iterator.next() + if (!skip && !continueLast) { + result.append(seg.separator()) + } + continueLast = false + when (seg.kind) { + SegmentKind.BINDING -> { + val `var` = seg.value + val value = values[seg.value] + if (value == null) { + if (!allowPartial) { + throw ValidationException( + String.format("Unbound variable '%s'. Bindings: %s", `var`, values)) + } + // Append pattern to output + if (`var`.startsWith("$")) { + // Eliminate positional variable. + result.append(iterator.next().value) + iterator.next() + } else { + result.append('{') + result.append(seg.value) + result.append('=') + continueLast = true + } + } else { + val next = iterator.next() + val nextNext = iterator.next() + val pathEscape = (next.kind == SegmentKind.PATH_WILDCARD || nextNext.kind != SegmentKind.END_BINDING) + restore(iterator, iterator.nextIndex() - 2) + if (!pathEscape) { + result.append(encodeUrl(value)) + } else { + // For a path wildcard or path of length greater 1, split the value and escape + // every sub-segment. + var first = true + for (subSeg in value.split('/').map { it.trim() }) { + if (!first) { + result.append('/') + } + first = false + result.append(encodeUrl(subSeg)) + } + } + skip = true + } + } + SegmentKind.END_BINDING -> { + if (!skip) { + result.append('}') + } + skip = false + } + else -> if (!skip) { + result.append(seg.value) + } + } + } + return result.toString() + } + + /** + * Instantiates the template from the given positional parameters. The template must not be build + * from named bindings, but only contain wildcards. Each parameter position corresponds to a + * wildcard of the according position in the template. + */ + fun encode(vararg values: String): String { + val builder = mutableMapOf() + var i = 0 + for (value in values) { + builder["$" + i++] = value + } + // We will get an error if there are named bindings which are not reached by values. + return instantiate(builder) + } + + /** + * Matches the template into a list of positional values. The template must not be build from + * named bindings, but only contain wildcards. For each wildcard in the template, a value is + * returned at corresponding position in the list. + */ + fun decode(path: String): List { + val match = match(path) ?: throw IllegalArgumentException( + String.format("template '%s' does not match '%s'", this, path)) + val result = mutableListOf() + for (entry in match.entries) { + val key = entry.key + if (!key.startsWith("$")) { + throw IllegalArgumentException("template must not contain named bindings") + } + val i = Integer.parseInt(key.substring(1)) + while (result.size <= i) { + result.add("") + } + result[i] = entry.value + } + return result + } + + private fun encodeUrl(text: String?): String { + return if (urlEncoding) { + try { + URLEncoder.encode(text!!, "UTF-8") + } catch (e: UnsupportedEncodingException) { + throw ValidationException("UTF-8 encoding is not supported on this platform") + } + } else { + // When encoding is disabled, we accept any character except '/' + val INVALID_CHAR = "/" + if (text!!.contains(INVALID_CHAR)) { + throw ValidationException("Invalid character \"$INVALID_CHAR\" in path section \"$text\".") + } + text + } + } + + private fun decodeUrl(url: String): String { + return if (urlEncoding) { + try { + URLDecoder.decode(url, "UTF-8") + } catch (e: UnsupportedEncodingException) { + throw ValidationException("UTF-8 encoding is not supported on this platform") + } + } else { + url + } + } + + /** + * Returns a pretty version of the template as a string. + */ + override fun toString(): String { + return toSyntax(segments, true) + } + + /** + * Returns a raw version of the template as a string. This renders the template in its internal, + * normalized form. + */ + fun toRawString(): String { + return toSyntax(segments, false) + } + + override fun equals(obj: Any?): Boolean { + if (!(obj is PathTemplate)) { + return false + } + val other = obj as PathTemplate? + return segments == other!!.segments + } + + override fun hashCode(): Int { + return segments.hashCode() + } + + companion object { + + /** + * A constant identifying the special variable used for endpoint bindings in the result of + * [.matchFromFullName]. It may also contain protocol string, if its provided in the + * input. + */ + val HOSTNAME_VAR = "\$hostname" + + // A regexp to match a custom verb at the end of a path. + private val CUSTOM_VERB_PATTERN = """:([^/*}{=]+)$""".toPattern() + + // A regex to match a hostname with or without protocol. + private val HOSTNAME_PATTERN = """^(w+:)?//""".toPattern() + + /** + * Creates a path template from a string. The string must satisfy the syntax of path templates of + * the API platform; see HttpRule's proto source. + * + * @throws ValidationException if there are errors while parsing the template. + */ + fun create(template: String): PathTemplate { + return create(template, true) + } + + /** + * Creates a path template from a string. The string must satisfy the syntax of path templates of + * the API platform; see HttpRule's proto source. Url encoding of template variables is disabled. + * + * @throws ValidationException if there are errors while parsing the template. + */ + fun createWithoutUrlEncoding(template: String): PathTemplate { + return create(template, false) + } + + private fun create(template: String, urlEncoding: Boolean): PathTemplate { + return PathTemplate(parseTemplate(template), urlEncoding) + } + + private fun concatCaptures(cur: String?, next: String): String { + return if (cur == null) next else "$cur/$next" + } + + private fun parseTemplate(template: String): List { + var template = template + val builder = mutableListOf() + + // Skip useless leading slash. + if (template.startsWith("/")) { + builder.add(Segment.EMPTY) + template = template.substring(1) + } + + // Extract trailing custom verb. + val matcher = CUSTOM_VERB_PATTERN.matcher(template) + var customVerb: String? = null + if (matcher.find()) { + customVerb = matcher.group(1) + template = template.substring(0, matcher.start(0)) + } + + var varName: String? = null + var freeWildcardCounter = 0 + var pathWildCardBound = 0 + + for (seg in template.split('/').map { it.trim() }) { + var seg = seg + // If segment starts with '{', a binding group starts. + val bindingStarts = seg.startsWith("{") + var implicitWildcard = false + if (bindingStarts) { + if (varName != null) { + throw ValidationException("parse error: nested binding in '%s'", template) + } + seg = seg.substring(1) + + val i = seg.indexOf('=') + if (i <= 0) { + // Possibly looking at something like "{name}" with implicit wildcard. + if (seg.endsWith("}")) { + // Remember to add an implicit wildcard later. + implicitWildcard = true + varName = seg.substring(0, seg.length - 1).trim { it <= ' ' } + seg = seg.substring(seg.length - 1).trim { it <= ' ' } + } else { + throw ValidationException("parse error: invalid binding syntax in '%s'", template) + } + } else { + // Looking at something like "{name=wildcard}". + varName = seg.substring(0, i).trim { it <= ' ' } + seg = seg.substring(i + 1).trim { it <= ' ' } + } + builder.add(Segment(SegmentKind.BINDING, varName)) + } + + // If segment ends with '}', a binding group ends. Remove the brace and remember. + val bindingEnds = seg.endsWith("}") + if (bindingEnds) { + seg = seg.substring(0, seg.length - 1).trim { it <= ' ' } + } + + // Process the segment, after stripping off "{name=.." and "..}". + when (seg) { + "**", "*" -> { + if ("**" == seg) { + pathWildCardBound++ + } + val wildcard = if (seg.length == 2) Segment.PATH_WILDCARD else Segment.WILDCARD + if (varName == null) { + // Not in a binding, turn wildcard into implicit binding. + // "*" => "{$n=*}" + builder.add(Segment(SegmentKind.BINDING, "$$freeWildcardCounter")) + freeWildcardCounter++ + builder.add(wildcard) + builder.add(Segment.END_BINDING) + } else { + builder.add(wildcard) + } + } + "" -> if (!bindingEnds) { + throw ValidationException( + "parse error: empty segment not allowed in '%s'", template) + } + else -> builder.add(Segment(SegmentKind.LITERAL, seg)) + } // If the wildcard is implicit, seg will be empty. Just continue. + + // End a binding. + if (bindingEnds) { + // Reset varName to null for next binding. + varName = null + + if (implicitWildcard) { + // Looking at something like "{var}". Insert an implicit wildcard, as it is the same + // as "{var=*}". + builder.add(Segment.WILDCARD) + } + builder.add(Segment.END_BINDING) + } + + if (pathWildCardBound > 1) { + // Report restriction on number of '**' in the pattern. There can be only one, which + // enables non-backtracking based matching. + throw ValidationException( + "parse error: pattern must not contain more than one path wildcard ('**') in '%s'", + template) + } + } + + if (customVerb != null) { + builder.add(Segment(SegmentKind.CUSTOM_VERB, customVerb)) + } + return builder + } + + // Checks for the given segments kind. On success, consumes them. Otherwise leaves + // the list iterator in its state. + private fun peek(segments: ListIterator, vararg kinds: SegmentKind): Boolean { + val start = segments.nextIndex() + var success = false + for (kind in kinds) { + if (!segments.hasNext() || segments.next().kind != kind) { + success = false + break + } + } + if (success) { + return true + } + restore(segments, start) + return false + } + + // Restores a list iterator back to a given index. + private fun restore(segments: ListIterator<*>, index: Int) { + while (segments.nextIndex() > index) { + segments.previous() + } + } + + private fun toSyntax(segments: List, pretty: Boolean): String { + val result = StringBuilder() + var continueLast = true // if true, no slash is appended. + val iterator = segments.listIterator() + while (iterator.hasNext()) { + var seg = iterator.next() + if (!continueLast) { + result.append(seg.separator()) + } + continueLast = false + when (seg.kind) { + SegmentKind.BINDING -> { + if (pretty && seg.value.startsWith("$")) { + // Remove the internal binding. + seg = iterator.next() // Consume wildcard + result.append(seg.value) + iterator.next() // Consume END_BINDING + } else { + result.append('{') + result.append(seg.value) + if (pretty && peek(iterator, SegmentKind.WILDCARD, SegmentKind.END_BINDING)) { + // Reduce {name=*} to {name}. + result.append('}') + } else { + result.append('=') + continueLast = true + } + } + } + SegmentKind.END_BINDING -> { + result.append('}') + } + else -> { + result.append(seg.value) + } + } + } + return result.toString() + } + } +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/ResourceName.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/ResourceName.kt new file mode 100644 index 00000000..5f81fb59 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/ResourceName.kt @@ -0,0 +1,161 @@ +package com.bybutter.sisyphus.api.resource + +import com.bybutter.sisyphus.collection.contentEquals +import com.bybutter.sisyphus.protobuf.CustomProtoType +import com.bybutter.sisyphus.protobuf.CustomProtoTypeSupport +import io.grpc.Metadata +import java.lang.reflect.Proxy + +interface ResourceName : CustomProtoType { + fun singular(): String + fun plural(): String + fun endpoint(): String? + fun template(): PathTemplate + + fun toMap(): Map + fun toMutableMap(): MutableMap + + operator fun contains(key: String): Boolean + operator fun iterator(): Iterator> + operator fun get(key: String): String? + operator fun plus(map: Map): ResourceName + + override fun toString(): String + override fun equals(other: Any?): Boolean + override fun hashCode(): Int + + companion object : CustomProtoTypeSupport { + override val rawType: Class = String::class.java + + const val WILDCARD_PART = "-" + + override fun wrapRaw(value: String): ResourceName { + return UnknownResourceName(value) + } + + inline operator fun invoke(path: String): T { + return UnknownResourceName(path, T::class.java) + } + } +} + +abstract class AbstractResourceName( + private val data: Map, + private val template: PathTemplate, + private val support: ResourceNameSupport +) : ResourceName { + private val name: String by lazy { + template().instantiate(data) + } + + private val bytes: ByteArray by lazy { + name.toByteArray() + } + + override fun toString(): String { + return name + } + + override fun plural(): String { + return support.plural + } + + override fun singular(): String { + return support.singular + } + + override fun template(): PathTemplate { + return template + } + + override fun endpoint(): String? { + return data[PathTemplate.HOSTNAME_VAR] + } + + override fun raw(): String { + return name + } + + override fun contains(key: String): Boolean { + return data.containsKey(key) + } + + override fun iterator(): Iterator> { + return data.iterator() + } + + override fun get(key: String): String? { + return data[key] + } + + override fun toMap(): Map { + return data + } + + override fun toMutableMap(): MutableMap { + return data.toMutableMap() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (this.javaClass != other?.javaClass) return false + other as AbstractResourceName + + if (template() != other.template()) return false + if (!data.contentEquals(other.data)) return false + if (endpoint() != other.endpoint()) return false + + return true + } + + override fun hashCode(): Int { + var result = this.javaClass.hashCode() + result = result * 31 + template().hashCode() + for ((key, value) in data) { + result = result * 31 + key.hashCode() + result = result * 31 + value.hashCode() + } + return result + } +} + +abstract class ResourceNameSupport : Metadata.AsciiMarshaller, CustomProtoTypeSupport { + abstract val type: String + + abstract val patterns: List + + abstract val plural: String + + abstract val singular: String + + override val rawType: Class = String::class.java + + override fun wrapRaw(value: String): T { + return invoke(value) + } + + abstract operator fun invoke(path: String): T + + fun matches(path: String): Boolean { + return patterns.any { + it.matches(path) + } + } + + fun tryCreate(path: String): T? { + val result = invoke(path) + return if (Proxy.isProxyClass(result.javaClass)) { + null + } else { + result + } + } + + override fun parseAsciiString(serialized: String): T { + return invoke(serialized) + } + + override fun toAsciiString(value: T): String { + return value.toString() + } +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/Segment.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/Segment.kt new file mode 100644 index 00000000..69202782 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/Segment.kt @@ -0,0 +1,52 @@ +package com.bybutter.sisyphus.api.resource + +/** + * Specifies a path segment. + */ +internal data class Segment( + /** + * The path segment kind. + */ +val kind: SegmentKind, + /** + * The value for the segment. For literals, custom verbs, and wildcards, this reflects the value + * as it appears in the template. For bindings, this represents the variable of the binding. + */ +val value: String +) { + + /** + * Returns true of this segment is one of the wildcards, + */ + val isAnyWildcard: Boolean + get() { + return kind == SegmentKind.WILDCARD || kind == SegmentKind.PATH_WILDCARD + } + + fun separator(): String { + return when (kind) { + SegmentKind.CUSTOM_VERB -> ":" + SegmentKind.END_BINDING -> "" + else -> "/" + } + } + + companion object { + val EMPTY = Segment(SegmentKind.LITERAL, "") + + /** + * A constant for the WILDCARD segment. + */ + val WILDCARD = Segment(SegmentKind.WILDCARD, "*") + + /** + * A constant for the PATH_WILDCARD segment. + */ + val PATH_WILDCARD = Segment(SegmentKind.PATH_WILDCARD, "**") + + /** + * A constant for the END_BINDING segment. + */ + val END_BINDING = Segment(SegmentKind.END_BINDING, "") + } +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/SegmentKind.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/SegmentKind.kt new file mode 100644 index 00000000..35cdcccc --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/SegmentKind.kt @@ -0,0 +1,24 @@ +package com.bybutter.sisyphus.api.resource + +/** + * Specifies a path segment kind. + */ +internal enum class SegmentKind { + /** A literal path segment. */ + LITERAL, + + /** A custom verb. Can only appear at the end of path. */ + CUSTOM_VERB, + + /** A simple wildcard ('*'). */ + WILDCARD, + + /** A path wildcard ('**'). */ + PATH_WILDCARD, + + /** A field binding start. */ + BINDING, + + /** A field binding end. */ + END_BINDING +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/TemplatedResourceName.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/TemplatedResourceName.kt new file mode 100644 index 00000000..b1ee84ae --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/TemplatedResourceName.kt @@ -0,0 +1,205 @@ +/* + * Copyright 2016, Google Inc. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.bybutter.sisyphus.api.resource + +import java.util.Objects + +/** + * Class for representing and working with resource names. + * + * + * + * A resource name is represented by [PathTemplate], an assignment to variables in the + * template, and an optional endpoint. The `ResourceName` class implements the map interface + * (unmodifiable) to work with the variable assignments, and has methods to reproduce the string + * representation of the name, to construct new names, and to dereference names into resources. + * + * + * + * As a resource name essentially represents a match of a path template against a string, it can be + * also used for other purposes than naming resources. However, not all provided methods may make + * sense in all applications. + * + * + * + * Usage examples: + * + * + * PathTemplate template = PathTemplate.create("shelves/*/books/*"); + * TemplatedResourceName resourceName = TemplatedResourceName.create(template, "shelves/s1/books/b1"); + * assert resourceName.get("$1").equals("b1"); + * assert resourceName.parentName().toString().equals("shelves/s1/books"); + * + */ +class TemplatedResourceName private constructor( + private val template: PathTemplate, + private val data: Map, + private val endpoint: String? +) : Map by data { + + private val stringRepr: String by lazy { + template.instantiate(data) + } + + /** + * Represents a resource name resolver which can be registered with this class. + */ + interface Resolver { + /** + * Resolves the resource name into a resource by calling the underlying API. + */ + fun resolve(resourceType: Class, name: TemplatedResourceName, version: String?): T + } + + override fun toString(): String { + return stringRepr + } + + override fun equals(obj: Any?): Boolean { + if (obj !is TemplatedResourceName) { + return false + } + return (template == obj.template && + endpoint == obj.endpoint && + values == obj.values) + } + + override fun hashCode(): Int { + return Objects.hash(template, endpoint, values) + } + + /** + * Gets the template associated with this resource name. + */ + fun template(): PathTemplate { + return template + } + + /** + * Checks whether the resource name has an endpoint. + */ + fun hasEndpoint(): Boolean { + return endpoint != null + } + + /** + * Returns the endpoint of this resource name, or null if none is defined. + */ + fun endpoint(): String? { + return endpoint + } + + /** + * Returns a resource name with specified endpoint. + */ + fun withEndpoint(endpoint: String?): TemplatedResourceName { + endpoint ?: throw NullPointerException("'endpoint' must be not null") + return TemplatedResourceName(template, data, endpoint) + } + + /** + * Returns the parent resource name. For example, if the name is `shelves/s1/books/b1`, the + * parent is `shelves/s1/books`. + */ + fun parentName(): TemplatedResourceName { + val parentTemplate = template.parentTemplate() + return TemplatedResourceName(parentTemplate, data, endpoint) + } + + /** + * Returns true of the resource name starts with the parent resource name, i.e. is a child of the + * parent. + */ + fun startsWith(parentName: TemplatedResourceName): Boolean { + // TODO: more efficient implementation. + return toString().startsWith(parentName.toString()) + } + + /** + * Attempts to resolve a resource name into a resource, by calling the associated API. The + * resource name must have an endpoint. An optional version can be specified to determine in which + * version of the API to call. + */ + fun resolve(resourceType: Class, version: String?): T { + if (hasEndpoint()) { + throw IllegalStateException("Resource name must have an endpoint.") + } + return resourceNameResolver.resolve(resourceType, this, version) + } + + companion object { + + // The registered resource name resolver. + // TODO(wrwg): its a bit spooky to have this static global. Think of ways to + // configure this from the outside instead if programmatically (e.g. java properties). + @Volatile + private var resourceNameResolver: Resolver = object : Resolver { + override fun resolve(resourceType: Class, name: TemplatedResourceName, version: String?): T { + throw IllegalStateException("No resource name resolver is registered in ResourceName class.") + } + } + + /** + * Sets the resource name resolver which is used by the [.resolve] method. By + * default, no resolver is registered. + */ + fun registerResourceNameResolver(resolver: Resolver) { + resourceNameResolver = resolver + } + + /** + * Creates a new resource name based on given template and path. The path must match the template, + * otherwise null is returned. + * + * @throws ValidationException if the path does not match the template. + */ + fun create(template: PathTemplate, path: String): TemplatedResourceName { + val values = template.match(path) + ?: throw ValidationException("path '%s' does not match template '%s'", path, template) + return TemplatedResourceName(template, values, null) + } + + /** + * Creates a new resource name from a template and a value assignment for variables. + * + * @throws ValidationException if not all variables in the template are bound. + */ + fun create(template: PathTemplate, values: Map): TemplatedResourceName { + if (!values.keys.containsAll(template.vars())) { + val unbound = template.vars().toMutableSet() + unbound.removeAll(values.keys) + throw ValidationException("unbound variables: %s", unbound) + } + return TemplatedResourceName(template, values, null) + } + } +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/UnknownResourceName.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/UnknownResourceName.kt new file mode 100644 index 00000000..0536a70c --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/UnknownResourceName.kt @@ -0,0 +1,105 @@ +package com.bybutter.sisyphus.api.resource + +import java.lang.reflect.InvocationHandler +import java.lang.reflect.Method +import java.lang.reflect.Proxy +import kotlin.reflect.full.companionObjectInstance + +class UnknownResourceName private constructor( + private val support: ResourceNameSupport, + private val name: String +) : ResourceName, InvocationHandler { + + override fun singular(): String { + return support.singular + } + + override fun plural(): String { + return support.plural + } + + override fun endpoint(): String? { + return this[PathTemplate.HOSTNAME_VAR] + } + + override fun template(): PathTemplate { + return patterns[0] + } + + override fun toMap(): Map { + return mapOf() + } + + override fun toMutableMap(): MutableMap { + return mutableMapOf() + } + + override fun contains(key: String): Boolean { + return false + } + + override fun iterator(): Iterator> { + return emptyMap().iterator() + } + + override fun get(key: String): String? { + return null + } + + override fun plus(map: Map): ResourceName { + return this + } + + override fun raw(): String { + return name + } + + override fun toString(): String { + return name + } + + override fun equals(other: Any?): Boolean { + return other?.toString() == name + } + + override fun hashCode(): Int { + return name.hashCode() + } + + override fun invoke(proxy: Any?, method: Method, args: Array?): Any? { + val args = args ?: arrayOf() + if (method.declaringClass == Any::class.java) { + return method.invoke(this, *args) + } + + if (method.declaringClass == ResourceName::class.java) { + return method.invoke(this, *args) + } + + if (Map::class.java.isAssignableFrom(method.declaringClass)) { + return method.invoke(this, *args) + } + + return "unknown" + } + + companion object : ResourceNameSupport() { + override val type: String = "butterapis.com/Resource" + override val patterns: List = listOf(PathTemplate.create("**")) + override val plural: String = "resources" + override val singular: String = "resource" + + override fun invoke(path: String): ResourceName { + return UnknownResourceName(UnknownResourceName, path) + } + + operator fun invoke(name: String, target: Class): T { + val support = (target.kotlin.companionObjectInstance as? ResourceNameSupport) ?: UnknownResourceName + return Proxy.newProxyInstance( + target.classLoader, + arrayOf(target), + UnknownResourceName(support, name) + ) as T + } + } +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/ValidationException.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/ValidationException.kt new file mode 100644 index 00000000..011a638c --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/api/resource/ValidationException.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2016, Google Inc. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.bybutter.sisyphus.api.resource + +import java.util.Stack + +/** + * Exception thrown if there is a validation problem with a path template, http config, or related + * framework methods. Comes as an illegal argument exception subclass. Allows to globally set a + * thread-local validation context description which each exception inherits. + */ +class ValidationException(format: String, vararg args: Any) : IllegalArgumentException(message(contextLocal.get(), format, *args)) { + interface Supplier { + fun get(): T + } + + companion object { + private val contextLocal = ThreadLocal>>() + + /** + * Sets the validation context description. Each thread has its own description, so this is thread + * safe. + */ + fun pushCurrentThreadValidationContext(supplier: Supplier) { + var stack: Stack>? = contextLocal.get() + if (stack == null) { + stack = Stack() + contextLocal.set(stack) + } + stack.push(supplier) + } + + fun pushCurrentThreadValidationContext(context: String) { + pushCurrentThreadValidationContext( + object : Supplier { + override fun get(): String { + return context + } + }) + } + + /** + * Clears the validation context. + */ + fun popCurrentThreadValidationContext() { + val stack = contextLocal.get() + stack?.pop() + } + + private fun message(context: Stack>?, format: String, vararg args: Any): String { + if (context == null || context.isEmpty()) { + return String.format(format, *args) + } + val result = StringBuilder() + for (supplier in context) { + result.append(supplier.get() + ": ") + } + return result.toString() + String.format(format, *args) + } + } +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/cel/CelContext.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/cel/CelContext.kt new file mode 100644 index 00000000..d2f1e984 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/cel/CelContext.kt @@ -0,0 +1,245 @@ +package com.bybutter.sisyphus.cel + +import com.bybutter.sisyphus.cel.grammar.CelParser +import com.bybutter.sisyphus.protobuf.InternalProtoApi +import com.bybutter.sisyphus.string.unescape + +@OptIn(InternalProtoApi::class) +class CelContext internal constructor(private val engine: CelEngine, global: Map = mapOf()) { + val global: MutableMap = global.toMutableMap() + + fun fork(): CelContext { + return CelContext(engine, this.global) + } + + fun visit(start: CelParser.StartContext): Any? { + val t = 1 + val expr = start.e ?: return null + return visit(expr) + } + + fun visit(expr: CelParser.ExprContext): Any? { + return if (expr.op != null) { + val condition = visit(expr.e) as? Boolean + ?: throw IllegalStateException("Conditional expr '${expr.e.text}' must be bool") + if (condition) { + visit(expr.e1) + } else { + visit(expr.e2) + } + } else { + visit(expr.e) + } + } + + fun visit(or: CelParser.ConditionalOrContext): Any? { + var result = visit(or.e) + + for (relation in or.e1) { + if (result == true) break + result = or(result, relation) + } + + return result + } + + private fun or(left: Any?, right: CelParser.ConditionalAndContext): Any? { + if (left == true) return left + if (left == false) return visit(right) + + val right = visit(right) + if (right == true) return right + if (right == false) return left + + TODO() + } + + fun visit(and: CelParser.ConditionalAndContext): Any? { + var result = visit(and.e) + + for (relation in and.e1) { + if (result == true) break + result = and(result, relation) + } + + return result + } + + private fun and(left: Any?, right: CelParser.RelationContext): Any? { + if (left == false) return left + if (left == true) return visit(right) + + val right = visit(right) + if (right == false) return right + if (right == true) return left + + TODO() + } + + fun visit(relation: CelParser.RelationContext): Any? { + relation.calc()?.let { + return visit(it) + } + + return when (relation.op.text) { + "<", "<=", ">", ">=" -> compare(relation.relation(0), relation.op.text, relation.relation(1)) + "==", "!=" -> equals(relation.relation(0), relation.op.text, relation.relation(1)) + "in" -> engine.runtime.invoke(null, "contains", visit(relation.relation(1)), visit(relation.relation(0))) + else -> TODO() + } + } + + private fun compare(left: CelParser.RelationContext, operator: String, right: CelParser.RelationContext): Boolean { + val result = engine.runtime.invoke(null, "compare", visit(left), visit(right)) as? Long + ?: throw IllegalStateException("Compare function must return CEL int(java.Long).") + return when (operator) { + "<" -> result < 0 + "<=" -> result <= 0 + ">" -> result > 0 + ">=" -> result >= 0 + else -> throw IllegalStateException("Wrong compare operator '$operator'.") + } + } + + private fun equals(left: CelParser.RelationContext, operator: String, right: CelParser.RelationContext): Boolean { + val result = engine.runtime.invoke(null, "equals", visit(left), visit(right)) as? Boolean + ?: throw IllegalStateException("Equals function must return CEL bool(java.Boolean).") + return when (operator) { + "==" -> result + "!=" -> !result + else -> throw IllegalStateException("Wrong equals operator '$operator'.") + } + } + + fun visit(calc: CelParser.CalcContext): Any? { + calc.unary()?.let { + return visit(it) + } + + return when (calc.op.text) { + "*" -> engine.runtime.invoke(null, "times", visit(calc.calc(0)), visit(calc.calc(1))) + "/" -> engine.runtime.invoke(null, "div", visit(calc.calc(0)), visit(calc.calc(1))) + "%" -> engine.runtime.invoke(null, "rem", visit(calc.calc(0)), visit(calc.calc(1))) + "+" -> engine.runtime.invoke(null, "plus", visit(calc.calc(0)), visit(calc.calc(1))) + "-" -> engine.runtime.invoke(null, "minus", visit(calc.calc(0)), visit(calc.calc(1))) + else -> TODO() + } + } + + fun visit(unary: CelParser.UnaryContext): Any? { + return when (unary) { + is CelParser.MemberExprContext -> { + visit(unary.member()) + } + is CelParser.LogicalNotContext -> { + engine.runtime.invoke(null, "logicalNot", visit(unary.member())) + } + is CelParser.NegateContext -> { + engine.runtime.invoke(null, "negate", visit(unary.member())) + } + else -> throw UnsupportedOperationException("Unsupported unary expression '${unary.text}'.") + } + } + + fun visit(member: CelParser.MemberContext): Any? { + return when (member) { + is CelParser.PrimaryExprContext -> { + visit(member.primary()) + } + is CelParser.SelectOrCallContext -> { + if (member.open != null) { + engine.runtime.invokeMarco(this, visit(member.member()), member.IDENTIFIER().text, member.args?.e + ?: listOf()) { return it } + engine.runtime.invoke(visit(member.member()), member.IDENTIFIER().text, member.args?.e?.map { visit(it) } + ?: listOf()) + } else { + if (member.text.startsWith(".")) { + engine.runtime.getGlobalField(member.text, global) + } else { + engine.runtime.invoke(null, "access", visit(member.member()), member.IDENTIFIER().text) + } + } + } + is CelParser.IndexContext -> { + engine.runtime.invoke(null, "index", visit(member.member()), visit(member.index)) + } + is CelParser.CreateMessageContext -> { + engine.runtime.createMessage(member.member().text, (member.fieldInitializerList()?.fields + ?: listOf()).asSequence().mapIndexed { index, token -> + token.text to visit(member.fieldInitializerList().values[index]) + }.associate { it }) + } + else -> throw UnsupportedOperationException("Unsupported member expression '${member.text}'.") + } + } + + fun visit(primary: CelParser.PrimaryContext): Any? { + return when (primary) { + is CelParser.IdentOrGlobalCallContext -> { + if (primary.op != null) { + engine.runtime.invokeMarco(this, null, primary.IDENTIFIER().text, primary.args?.e + ?: listOf()) { return it } + engine.runtime.invoke(null, primary.IDENTIFIER().text, primary.args?.e?.map { visit(it) } + ?: listOf()) + } else { + engine.runtime.getGlobalField(primary.text, global) + } + } + is CelParser.NestedContext -> visit(primary.expr()) + is CelParser.CreateListContext -> primary.elems?.e?.map { visit(it) } ?: listOf() + is CelParser.CreateStructContext -> { + primary.entries?.keys?.withIndex()?.associate { (index, key) -> + key.text to visit(primary.entries.values[index]) + } ?: mapOf() + } + is CelParser.ConstantLiteralContext -> visit(primary.literal()) + else -> throw UnsupportedOperationException("Unsupported primary expression '${primary.text}'.") + } + } + + fun visit(literal: CelParser.LiteralContext): Any? { + return when (literal) { + is CelParser.IntContext -> literal.text.toLong() + is CelParser.UintContext -> literal.text.toULong() + is CelParser.DoubleContext -> literal.text.toDouble() + is CelParser.StringContext -> celString(literal.text) + is CelParser.BytesContext -> celBytes(literal.text) + is CelParser.BoolTrueContext -> true + is CelParser.BoolFalseContext -> false + is CelParser.NullContext -> null + else -> throw UnsupportedOperationException("Unsupported literal expression '${literal.text}'.") + } + } + + private fun celBytes(data: String): ByteArray { + return if (data.startsWith("b") || data.startsWith("B")) { + celString(data.substring(1)).toByteArray() + } else { + throw IllegalStateException("Wrong bytes token '$data'.") + } + } + + private fun celString(data: String): String { + val rawMode: Boolean + val string = if (data.startsWith("r") || data.startsWith("R")) { + rawMode = true + data.subSequence(1, data.length) + } else { + rawMode = false + data + } + + val rawString = when { + string.startsWith("\"\"\"") -> string.substring(3, string.length - 3) + string.startsWith("\"") -> string.substring(1, string.length - 1) + string.startsWith("'") -> string.substring(1, string.length - 1) + else -> throw IllegalStateException("Wrong string token '$data'.") + } + + return if (rawMode) { + rawString + } else { + rawString.unescape() + } + } +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/cel/CelEngine.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/cel/CelEngine.kt new file mode 100644 index 00000000..410e5adb --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/cel/CelEngine.kt @@ -0,0 +1,33 @@ +package com.bybutter.sisyphus.cel + +import com.bybutter.sisyphus.cel.grammar.CelLexer +import com.bybutter.sisyphus.cel.grammar.CelParser +import org.antlr.v4.runtime.CharStreams +import org.antlr.v4.runtime.CommonTokenStream + +class CelEngine(global: Map, val runtime: CelRuntime = CelRuntime()) { + val context = CelContext(this, global) + + fun eval(cel: String): Any? { + return context.visit(parse(cel)) + } + + fun eval(cel: String, global: Map): Any? { + val context = context.fork() + context.global += global + return context.visit(parse(cel)) + } + + companion object { + fun parse(cel: String): CelParser.StartContext { + val lexer = CelLexer(CharStreams.fromString(cel)) + val parser = CelParser(CommonTokenStream(lexer)) + return parser.start() + } + } +} + +fun main() { + val engine = CelEngine(mapOf("intValue" to 1L, "test" to 1)) + engine +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/cel/CelMacro.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/cel/CelMacro.kt new file mode 100644 index 00000000..49b84bba --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/cel/CelMacro.kt @@ -0,0 +1,115 @@ +package com.bybutter.sisyphus.cel + +import com.bybutter.sisyphus.cel.grammar.CelParser +import com.bybutter.sisyphus.protobuf.Message + +open class CelMacro { + open fun has(context: CelContext, expr: CelParser.ExprContext): Boolean { + if (!expr.text.matches(CelRuntime.memberRegex)) { + throw IllegalArgumentException("Argument of 'has' macro '${expr.text}' must be a field selection.") + } + + val part = expr.text.trim('.').split(".") + var target: Any = context.global + for (s in part) { + when (target) { + is Map<*, *> -> { + if (!target.containsKey(s)) return false + target = target[s] ?: return false + } + is Message<*, *> -> { + if (target.support().fieldInfo(s) == null) return false + if (!target.has(s)) return false + target = target[s] + } + else -> throw IllegalStateException("Just 'map' and 'message' type support nested field in 'has' macro.") + } + } + + return true + } + + open fun List<*>.all(context: CelContext, name: CelParser.ExprContext, expr: CelParser.ExprContext): Boolean { + if (!name.text.matches(CelRuntime.idRegex)) { + throw IllegalArgumentException("Argument1 of 'all' macro '${name.text}' must be a identifier.") + } + val newContext = context.fork() + + return this.all { + newContext.global[name.text] = it + newContext.visit(expr) as? Boolean + ?: throw IllegalArgumentException("Argument2 of 'all' macro '${expr.text}' must return a bool.") + } + } + + open fun Map<*, *>.all(context: CelContext, name: CelParser.ExprContext, expr: CelParser.ExprContext): Boolean { + return this.keys.toList().all(context, name, expr) + } + + open fun List<*>.exists(context: CelContext, name: CelParser.ExprContext, expr: CelParser.ExprContext): Boolean { + if (!name.text.matches(CelRuntime.idRegex)) { + throw IllegalArgumentException("Argument1 of 'exists' macro '${name.text}' must be a identifier.") + } + val newContext = context.fork() + + return this.any { + newContext.global[name.text] = it + newContext.visit(expr) as? Boolean + ?: throw IllegalArgumentException("Argument2 of 'exists' macro '${expr.text}' must return a bool.") + } + } + + open fun Map<*, *>.exists(context: CelContext, name: CelParser.ExprContext, expr: CelParser.ExprContext): Boolean { + return this.keys.toList().exists(context, name, expr) + } + + open fun List<*>.exists_one(context: CelContext, name: CelParser.ExprContext, expr: CelParser.ExprContext): Boolean { + if (!name.text.matches(CelRuntime.idRegex)) { + throw IllegalArgumentException("Argument1 of 'exists_one' macro '${name.text}' must be a identifier.") + } + val newContext = context.fork() + + var counter: Int = 0 + + for (value in this) { + newContext.global[name.text] = value + val result = newContext.visit(expr) as? Boolean + ?: throw IllegalArgumentException("Argument2 of 'exists_one' macro '${expr.text}' must return a bool.") + if (result) { + counter++ + if (counter > 1) return false + } + } + + return counter == 1 + } + + open fun Map<*, *>.exists_one(context: CelContext, name: CelParser.ExprContext, expr: CelParser.ExprContext): Boolean { + return this.keys.toList().exists_one(context, name, expr) + } + + open fun List<*>.map(context: CelContext, name: CelParser.ExprContext, expr: CelParser.ExprContext): List<*> { + if (!name.text.matches(CelRuntime.idRegex)) { + throw IllegalArgumentException("Argument1 of 'map' macro '${name.text}' must be a identifier.") + } + val newContext = context.fork() + + return this.map { + newContext.global[name.text] = it + newContext.visit(expr) + } + } + + open fun List<*>.filter(context: CelContext, name: CelParser.ExprContext, expr: CelParser.ExprContext): List<*> { + if (!name.text.matches(CelRuntime.idRegex)) { + throw IllegalArgumentException("Argument1 of 'filter' macro '${name.text}' must be a identifier.") + } + val newContext = context.fork() + + return this.filter { + newContext.global[name.text] = it + newContext.visit(expr) as? Boolean + ?: throw IllegalArgumentException("Argument2 of 'filter' macro '${expr.text}' must return a bool.") + } + } +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/cel/CelRuntime.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/cel/CelRuntime.kt new file mode 100644 index 00000000..18bc26e8 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/cel/CelRuntime.kt @@ -0,0 +1,198 @@ +package com.bybutter.sisyphus.cel + +import com.bybutter.sisyphus.cel.grammar.CelParser +import com.bybutter.sisyphus.protobuf.CustomProtoType +import com.bybutter.sisyphus.protobuf.CustomProtoTypeSupport +import com.bybutter.sisyphus.protobuf.InternalProtoApi +import com.bybutter.sisyphus.protobuf.ProtoTypes +import kotlin.reflect.KClass +import kotlin.reflect.KFunction +import kotlin.reflect.KParameter +import kotlin.reflect.full.companionObjectInstance +import kotlin.reflect.full.extensionReceiverParameter +import kotlin.reflect.full.isSubtypeOf +import kotlin.reflect.full.memberExtensionFunctions +import kotlin.reflect.full.memberFunctions +import kotlin.reflect.full.starProjectedType +import kotlin.reflect.full.valueParameters +import kotlin.reflect.jvm.javaMethod + +open class CelRuntime(protected val macro: CelMacro = CelMacro(), protected val std: CelStandardLibrary = CelStandardLibrary()) { + private val memberFunctions = mutableMapOf>>() + + private val macroFunctions = mutableMapOf>>() + + init { + for (memberFunction in std.javaClass.kotlin.memberFunctions) { + if (memberFunction.javaMethod?.canAccess(std) != true) continue + memberFunctions.getOrPut(memberFunction.name) { mutableListOf() } += memberFunction + } + + for (memberFunction in std.javaClass.kotlin.memberExtensionFunctions) { + if (memberFunction.javaMethod?.canAccess(std) != true) continue + memberFunctions.getOrPut(memberFunction.name) { mutableListOf() } += memberFunction + } + + for (macroFunction in macro.javaClass.kotlin.memberFunctions) { + if (macroFunction.javaMethod?.canAccess(macro) != true) continue + macroFunctions.getOrPut(macroFunction.name) { mutableListOf() } += macroFunction + } + + for (macroFunction in macro.javaClass.kotlin.memberExtensionFunctions) { + if (macroFunction.javaMethod?.canAccess(macro) != true) continue + macroFunctions.getOrPut(macroFunction.name) { mutableListOf() } += macroFunction + } + } + + private fun KFunction<*>.macroCompatibleWith(th: Any?, arguments: List): Boolean { + val thType = this.extensionReceiverParameter?.type?.classifier as? KClass<*> + if (th == null && thType == null) return macroCompatibleWith(arguments) + if (th == null || thType == null) return false + if (!thType.isInstance(th)) return false + return macroCompatibleWith(arguments) + } + + private fun KFunction<*>.macroCompatibleWith(arguments: List): Boolean { + var usedExpr = 0 + var hasVar = false + val parameters = valueParameters + + loop@ for ((index, parameter) in parameters.withIndex()) { + val valueKClass = parameter.type.classifier as KClass<*> + when (valueKClass) { + CelContext::class -> { + if (parameter == parameters.first()) continue@loop + return false + } + CelParser.ExprContext::class -> { + usedExpr++ + if (usedExpr > arguments.size) return false + } + Array::class -> { + if (parameter.isVararg) { + hasVar = true + continue@loop + } + return false + } + else -> return false + } + } + + if (usedExpr == arguments.size) return true + if (hasVar && usedExpr < arguments.size) return true + return false + } + + private fun KFunction<*>.compatibleWith(th: Any?, arguments: List): Boolean { + val thType = this.extensionReceiverParameter?.type?.classifier as? KClass<*> + if (th == null && thType == null) return compatibleWith(listOfNotNull(this.extensionReceiverParameter) + this.valueParameters, arguments) + if (th == null || thType == null) return false + if (!thType.isInstance(th)) return false + return compatibleWith(valueParameters, arguments) + } + + private fun KFunction<*>.compatibleWith(arguments: List): Boolean { + return compatibleWith(listOfNotNull(this.extensionReceiverParameter) + this.valueParameters, arguments) + } + + private fun KFunction<*>.compatibleWith(parameters: List, arguments: List): Boolean { + if (parameters.size != arguments.size) return false + for ((index, parameter) in parameters.withIndex()) { + val type = arguments[index]?.javaClass + if (type == null && parameter.type.isMarkedNullable) continue + if (type == null) return false + if (!(parameter.type.classifier as KClass<*>).isInstance(arguments[index])) return false + } + + return true + } + + fun getGlobalField(key: String, global: Map): Any? { + return when (key) { + "int" -> "int" + "uint" -> "uint" + "double" -> "double" + "bool" -> "bool" + "string" -> "string" + "bytes" -> "bytes" + "list" -> "list" + "map" -> "map" + "null_type" -> "null_type" + else -> { + if (key.startsWith(".")) { + ProtoTypes.getSupportByProtoName(key)?.let { return ".${it.fullName}" } + } + return global[key] ?: throw NoSuchFieldException("No such field named '$key' in CEL global space.") + } + } + } + + fun invoke(th: Any?, function: String, vararg arguments: Any?): Any? { + return invoke(th, function, arguments.toList()) + } + + fun invoke(th: Any?, function: String, arguments: List): Any? { + return if (th == null) { + val func = memberFunctions[function]?.firstOrNull { + it.compatibleWith(arguments) + } ?: throw NoSuchMethodException( + "Can't find method '$function(${arguments.joinToString(", ") { it?.javaClass?.canonicalName ?: "null" }})' in CEL standard library." + ) + func.call(std, *arguments.toTypedArray()) + } else { + val func = memberFunctions[function]?.firstOrNull { + it.compatibleWith(th, arguments) + } ?: throw NoSuchMethodException( + "Can't find method '${th.javaClass.canonicalName}.$function(${arguments.joinToString(", ") { it?.javaClass?.canonicalName ?: "null" }})' in CEL standard library." + ) + func.call(std, th, *arguments.toTypedArray()) + } + } + + @OptIn(InternalProtoApi::class) + fun createMessage(type: String, initializer: Map): Any { + val messageSupport = ProtoTypes.ensureSupportByProtoName(type) + return messageSupport.newMutable().apply { + for ((key, value) in initializer) { + value ?: continue + val property = getProperty(key) + ?: throw IllegalStateException("Message type '$type' has not field '$key'.") + this[key] = if (property.returnType.isSubtypeOf(CustomProtoType::class.starProjectedType)) { + val support = (property.returnType.classifier as KClass<*>).companionObjectInstance as CustomProtoTypeSupport, Any?> + support.wrapRaw(value) + } else { + value + } + } + } + } + + fun findMarcoFunction(th: Any?, function: String?, arguments: List): KFunction<*>? { + return if (th == null) { + macroFunctions[function]?.firstOrNull { + it.macroCompatibleWith(arguments) + } + } else { + macroFunctions[function]?.firstOrNull { + it.macroCompatibleWith(th, arguments) + } + } + } + + inline fun invokeMarco(context: CelContext, th: Any?, function: String?, arguments: List, block: (Any?) -> Unit) { + val function = findMarcoFunction(th, function, arguments) ?: return + val result = if (th == null) { + function.call(macro, context, *arguments.toTypedArray()) + } else { + function.call(macro, th, context, *arguments.toTypedArray()) + } + block(result) + } + + companion object { + internal val idRegex = """^[_a-zA-Z][_a-zA-Z0-9]*$""".toRegex() + + internal val memberRegex = """^\.?[_a-zA-Z][_a-zA-Z0-9]*(?:\.[_a-zA-Z][_a-zA-Z0-9]*)*$""".toRegex() + } +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/cel/CelStandardLibrary.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/cel/CelStandardLibrary.kt new file mode 100644 index 00000000..d0d82f66 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/cel/CelStandardLibrary.kt @@ -0,0 +1,576 @@ +package com.bybutter.sisyphus.cel + +import com.bybutter.sisyphus.protobuf.CustomProtoType +import com.bybutter.sisyphus.protobuf.Message +import com.bybutter.sisyphus.protobuf.ProtoEnum +import com.bybutter.sisyphus.protobuf.primitives.BoolValue +import com.bybutter.sisyphus.protobuf.primitives.BytesValue +import com.bybutter.sisyphus.protobuf.primitives.DoubleValue +import com.bybutter.sisyphus.protobuf.primitives.Duration +import com.bybutter.sisyphus.protobuf.primitives.FloatValue +import com.bybutter.sisyphus.protobuf.primitives.Int32Value +import com.bybutter.sisyphus.protobuf.primitives.Int64Value +import com.bybutter.sisyphus.protobuf.primitives.ListValue +import com.bybutter.sisyphus.protobuf.primitives.NullValue +import com.bybutter.sisyphus.protobuf.primitives.StringValue +import com.bybutter.sisyphus.protobuf.primitives.Struct +import com.bybutter.sisyphus.protobuf.primitives.Timestamp +import com.bybutter.sisyphus.protobuf.primitives.UInt32Value +import com.bybutter.sisyphus.protobuf.primitives.UInt64Value +import com.bybutter.sisyphus.protobuf.primitives.Value +import com.bybutter.sisyphus.protobuf.primitives.compareTo +import com.bybutter.sisyphus.protobuf.primitives.invoke +import com.bybutter.sisyphus.protobuf.primitives.minus +import com.bybutter.sisyphus.protobuf.primitives.plus +import com.bybutter.sisyphus.protobuf.primitives.toInstant +import com.bybutter.sisyphus.protobuf.primitives.toSeconds +import com.bybutter.sisyphus.protobuf.primitives.toTime +import com.bybutter.sisyphus.security.base64 +import java.time.DayOfWeek +import java.time.ZoneOffset +import java.util.Arrays +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalUnsignedTypes::class) +open class CelStandardLibrary { + + // !_ + open fun logicalNot(value: Boolean): Boolean { + return !value + } + + // -_ + open fun negate(value: Long): Long { + return -value + } + + // -_ + open fun negate(value: Double): Double { + return -value + } + + // _==_ _!=_ + open fun equals(left: Any?, right: Any?): Boolean { + return left == right + } + + // _%_ + open fun rem(left: Long, right: Long): Long { + return left % right + } + + // _%_ + open fun rem(left: ULong, right: ULong): ULong { + return left % right + } + + // _&&_ + open fun logicalAnd(left: Boolean, right: Boolean): Boolean { + return left && right + } + + // _*_ + open fun times(left: Long, right: Long): Long { + return left * right + } + + // _*_ + open fun times(left: ULong, right: ULong): ULong { + return left * right + } + + // _*_ + open fun times(left: Double, right: Double): Double { + return left * right + } + + // _+_ + open fun plus(left: Long, right: Long): Long { + return left + right + } + + // _+_ + open fun plus(left: ULong, right: ULong): ULong { + return left + right + } + + // _+_ + open fun plus(left: Double, right: Double): Double { + return left + right + } + + // _+_ + open fun plus(left: String, right: String): String { + return left + right + } + + // _+_ + open fun plus(left: ByteArray, right: ByteArray): ByteArray { + return left + right + } + + // _+_ + open fun plus(left: List<*>, right: List<*>): List<*> { + return left + right + } + + // _+_ + open fun plus(left: Timestamp, right: Duration): Timestamp { + return left + right + } + + // _+_ + open fun plus(left: Duration, right: Timestamp): Timestamp { + return right + left + } + + // _+_ + open fun plus(left: Duration, right: Duration): Duration { + return left + right + } + + // _-_ + open fun minus(left: Long, right: Long): Long { + return left - right + } + + // _-_ + open fun minus(left: ULong, right: ULong): ULong { + return left - right + } + + // _-_ + open fun minus(left: Double, right: Double): Double { + return left - right + } + + // _-_ + open fun minus(left: Timestamp, right: Duration): Timestamp { + return left - right + } + + // _-_ + open fun minus(left: Timestamp, right: Timestamp): Duration { + return left - right + } + + // _-_ + open fun minus(left: Duration, right: Duration): Duration { + return left - right + } + + // _/_ + open fun div(left: Long, right: Long): Long { + return left / right + } + + // _/_ + open fun div(left: ULong, right: ULong): ULong { + return left / right + } + + // _/_ + open fun div(left: Double, right: Double): Double { + return left / right + } + + // _<_ _<=_ _>_ _>=_ + open fun compare(left: Long, right: Long): Long { + return left.compareTo(right).toLong() + } + + // _<_ _<=_ _>_ _>=_ + open fun compare(left: ULong, right: ULong): Long { + return left.compareTo(right).toLong() + } + + // _<_ _<=_ _>_ _>=_ + open fun compare(left: Double, right: Double): Long { + return left.compareTo(right).toLong() + } + + // _<_ _<=_ _>_ _>=_ + open fun compare(left: String, right: String): Long { + return left.compareTo(right).toLong() + } + + // _<_ _<=_ _>_ _>=_ + open fun compare(left: ByteArray, right: ByteArray): Long { + return Arrays.compare(left, right).toLong() + } + + // _<_ _<=_ _>_ _>=_ + open fun compare(left: Timestamp, right: Timestamp): Long { + return left.compareTo(right).toLong() + } + + // _<_ _<=_ _>_ _>=_ + open fun compare(left: Duration, right: Duration): Long { + return left.compareTo(right).toLong() + } + + // _?_:_ + open fun conditional(condition: Boolean, value1: Any?, value2: Any?): Any? { + return if (condition) value1 else value2 + } + + // _[_] + open fun index(list: List<*>, index: Long): Any? { + return list[index.toInt()] + } + + // _[_] + open fun index(map: Map<*, *>, index: Any?): Any? { + return map[index] + } + + // _._ + open fun access(map: Map<*, *>, index: Any?): Any? { + return map[index] + } + + // _._ + open fun access(message: Message<*, *>, index: String): Any? { + return message.get(index).protobufConversion() + } + + private fun Any?.protobufConversion(): Any? { + return when (this) { + is Int -> toLong() + is UInt -> toULong() + is Float -> toDouble() + is ListValue -> this.values.map { it.protobufConversion() } + is DoubleValue -> this.value + is FloatValue -> this.value.toDouble() + is Int64Value -> this.value + is UInt64Value -> this.value + is Int32Value -> this.value.toLong() + is UInt32Value -> this.value.toULong() + is BoolValue -> this.value + is StringValue -> this.value + is BytesValue -> this.value + is NullValue -> null + is Struct -> this.fields.mapValues { it.value.protobufConversion() } + is Value -> when (val kind = this.kind) { + is Value.Kind.BoolValue -> kind.value + is Value.Kind.ListValue -> kind.value.protobufConversion() + is Value.Kind.NullValue -> null + is Value.Kind.NumberValue -> kind.value + is Value.Kind.StringValue -> kind.value + is Value.Kind.StructValue -> kind.value + null -> null + else -> throw IllegalStateException("Illegal proto value type '${kind.javaClass}'.") + } + is ProtoEnum -> this.number.toLong() + is List<*> -> this.map { it.protobufConversion() } + is Map<*, *> -> this.mapValues { it.value.protobufConversion() } + is CustomProtoType<*> -> this.raw().protobufConversion() + null -> null + is Long, is ULong, is Double, is Boolean, is ByteArray, is String, is Message<*, *> -> this + else -> throw IllegalStateException("Illegal proto data type '${this.javaClass}'.") + } + } + + // _in_ + open fun contains(map: Map<*, *>, key: Any?): Boolean { + return key in map + } + + // _in_ + open fun contains(map: List<*>, key: Any?): Boolean { + return key in map + } + + // string.(string) -> bool + open fun String.contains(other: String): Boolean { + return this.contains(other, false) + } + + // _||_ + open fun logicalOr(left: Boolean, right: Boolean): Boolean { + return left || right + } + + // bytes(_) + open fun bytes(value: String): ByteArray { + return value.toByteArray() + } + + open fun double(value: Long): Double { + return value.toDouble() + } + + open fun double(value: ULong): Double { + return value.toDouble() + } + + open fun double(value: String): Double { + return value.toDouble() + } + + open fun duration(value: String): Duration { + return Duration(value) + } + + open fun String.endsWith(value: String): Boolean { + return this.endsWith(value, false) + } + + open fun type(value: Any?): String { + return when (value) { + is Long -> "int" + is ULong -> "uint" + is Double -> "double" + is Boolean -> "bool" + is String -> "string" + is ByteArray -> "bytes" + is List<*> -> "list" + is Map<*, *> -> "map" + null -> "null_type" + is Message<*, *> -> ".${value.type()}" + else -> "unknown" + } + } + + // open fun Timestamp.getDate(): String { + // return this.toInstant().atOffset(ZoneOffset.UTC).toLocalDate().toString() + // } + + // open fun Timestamp.getDate(timezone: String): String { + // return this.toInstant().atOffset(ZoneOffset.of(timezone)).toLocalDate().toString() + // } + + /** + * Get day of month from the date in UTC, zero-based indexing + */ + open fun Timestamp.getDayOfMonth(): Long { + return getDayOfMonth(ZoneOffset.UTC.id) + } + + /** + * Get day of month from the date with timezone, zero-based indexing + */ + open fun Timestamp.getDayOfMonth(timezone: String): Long { + return this.toInstant().atOffset(ZoneOffset.of(timezone)).dayOfMonth.toLong() - 1 + } + + /** + * Get day of week from the date in UTC, zero-based, zero for Sunday + */ + open fun Timestamp.getDayOfWeek(): Long { + return getDayOfWeek(ZoneOffset.UTC.id) + } + + /** + * Get day of week from the date with timezone, zero-based, zero for Sunday + */ + open fun Timestamp.getDayOfWeek(timezone: String): Long { + return when (this.toInstant().atOffset(ZoneOffset.of(timezone)).dayOfWeek) { + DayOfWeek.MONDAY -> 1 + DayOfWeek.TUESDAY -> 2 + DayOfWeek.WEDNESDAY -> 3 + DayOfWeek.THURSDAY -> 4 + DayOfWeek.FRIDAY -> 5 + DayOfWeek.SATURDAY -> 6 + DayOfWeek.SUNDAY -> 0 + } + } + + /** + * Get day of year from the date in UTC, zero-based indexing + */ + open fun Timestamp.getDayOfYear(): Long { + return getDayOfYear(ZoneOffset.UTC.id) + } + + /** + * Get day of year from the date with timezone, zero-based indexing + */ + open fun Timestamp.getDayOfYear(timezone: String): Long { + return this.toInstant().atOffset(ZoneOffset.of(timezone)).dayOfYear.toLong() - 1 + } + + /** + * Get year from the date in UTC + */ + open fun Timestamp.getFullYear(): Long { + return getFullYear(ZoneOffset.UTC.id) + } + + /** + * Get year from the date with timezone + */ + open fun Timestamp.getFullYear(timezone: String): Long { + return this.toInstant().atOffset(ZoneOffset.of(timezone)).year.toLong() + } + + /** + * Get hours from the date in UTC, 0-23 + */ + open fun Timestamp.getHours(): Long { + return getHours(ZoneOffset.UTC.id) + } + + /** + * Get hours from the date with timezone, 0-23 + */ + open fun Timestamp.getHours(timezone: String): Long { + return this.toInstant().atOffset(ZoneOffset.of(timezone)).hour.toLong() + } + + /** + * Get hours from duration + */ + open fun Duration.getHours(): Long { + return this.toTime(TimeUnit.HOURS) + } + + /** + * Get milliseconds from the date in UTC, 0-999 + */ + open fun Timestamp.getMilliseconds(): Long { + return getMilliseconds(ZoneOffset.UTC.id) + } + + /** + * Get milliseconds from the date with timezone, 0-999 + */ + open fun Timestamp.getMilliseconds(timezone: String): Long { + return TimeUnit.NANOSECONDS.toMillis(this.toInstant().atOffset(ZoneOffset.of(timezone)).nano.toLong()) + } + + /** + * Get milliseconds from duration, 0-999 + */ + open fun Duration.getMilliseconds(): Long { + return TimeUnit.NANOSECONDS.toMillis(this.nanos.toLong()) + } + + /** + * Get minutes from the date in UTC, 0-59 + */ + open fun Timestamp.getMinutes(): Long { + return getMinutes(ZoneOffset.UTC.id) + } + + /** + * Get minutes from the date with timezone, 0-59 + */ + open fun Timestamp.getMinutes(timezone: String): Long { + return this.toInstant().atOffset(ZoneOffset.of(timezone)).minute.toLong() + } + + /** + * Get minutes from duration + */ + open fun Duration.getMinutes(): Long { + return this.toTime(TimeUnit.MINUTES) + } + + /** + * Get month from the date in UTC, 0-11 + */ + open fun Timestamp.getMonth(): Long { + return getMonth(ZoneOffset.UTC.id) + } + + /** + * Get month from the date with timezone, 0-11 + */ + open fun Timestamp.getMonth(timezone: String): Long { + return this.toInstant().atOffset(ZoneOffset.of(timezone)).monthValue.toLong() - 1 + } + + /** + * Get seconds from the date in UTC, 0-59 + */ + open fun Timestamp.getSeconds(): Long { + return getSeconds(ZoneOffset.UTC.id) + } + + /** + * Get seconds from the date with timezone, 0-59 + */ + open fun Timestamp.getSeconds(timezone: String): Long { + return this.toInstant().atOffset(ZoneOffset.of(timezone)).second.toLong() + } + + /** + * Get seconds from duration + */ + open fun Duration.getSeconds(): Long { + return this.seconds + } + + open fun int(value: ULong): Long { + return value.toLong() + } + + open fun int(value: Double): Long { + return value.toLong() + } + + open fun int(value: String): Long { + return value.toLong() + } + + open fun int(value: Timestamp): Long { + return value.toSeconds() + } + + open fun String.matches(regex: String): Boolean { + return this.matches(regex.toRegex()) + } + + open fun size(value: String): Long { + return value.length.toLong() + } + + open fun size(value: ByteArray): Long { + return value.size.toLong() + } + + open fun size(value: List<*>): Long { + return value.size.toLong() + } + + open fun size(value: Map<*, *>): Long { + return value.size.toLong() + } + + open fun String.startsWith(value: String): Boolean { + return this.startsWith(value, false) + } + + open fun string(value: Long): String { + return value.toString() + } + + open fun string(value: ULong): String { + return value.toString() + } + + open fun string(value: Double): String { + return value.toString() + } + + open fun string(value: ByteArray): String { + return value.base64() + } + + open fun timestamp(value: String): Timestamp { + return Timestamp(value) + } + + open fun uint(value: Long): ULong { + return value.toULong() + } + + open fun uint(value: Double): ULong { + return value.toULong() + } + + open fun uint(value: String): ULong { + return value.toULong() + } +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/Adpter.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/Adpter.kt new file mode 100644 index 00000000..92a33882 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/Adpter.kt @@ -0,0 +1,81 @@ +package com.bybutter.sisyphus.rpc + +import io.grpc.stub.StreamObserver +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.launch + +fun StreamObserver.asSender(): Sender { + return StreamObserverAsSender(this) +} + +private class StreamObserverAsSender(private val observer: StreamObserver) : Sender { + override fun send(element: T) { + observer.onNext(element) + } + + override fun close(cause: Throwable?) { + if (cause != null) { + observer.onError(cause) + } else { + observer.onCompleted() + } + } +} + +fun CompletableDeferred.asStreamObserver(): StreamObserver { + return DeferredAsStreamObserver(this) +} + +private class DeferredAsStreamObserver( + private val deferred: CompletableDeferred +) : StreamObserver { + override fun onNext(value: T) { + deferred.complete(value) + } + + override fun onError(t: Throwable) { + deferred.completeExceptionally(t) + } + + override fun onCompleted() {} +} + +fun Channel.asStreamObserver(): StreamObserver { + return StreamObserverWithChannel(this) +} + +private class StreamObserverWithChannel( + private val channel: Channel +) : StreamObserver { + override fun onNext(value: T) { + channel.offer(value) + } + + override fun onError(exception: Throwable?) { + channel.close(exception) + } + + override fun onCompleted() { + channel.close(null) + } +} + +fun channelToStreamObserver( + channel: ReceiveChannel, + observer: StreamObserver +): Job = GlobalScope.launch { + try { + channel.consumeEach { + observer.onNext(it) + } + } catch (e: Exception) { + observer.onError(e) + throw e + } + observer.onCompleted() +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/ClientCalls.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/ClientCalls.kt new file mode 100644 index 00000000..41a413c3 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/ClientCalls.kt @@ -0,0 +1,54 @@ +package com.bybutter.sisyphus.rpc + +import io.grpc.ClientCall +import io.grpc.ForwardingClientCall +import io.grpc.ForwardingClientCallListener +import io.grpc.Metadata +import io.grpc.Status + +fun ClientCall.withHeader(headers: Metadata): ClientCall { + return ClientCallWithHeader(this, headers) +} + +fun ClientCall.withListener(listener: ClientCall.Listener): ClientCall { + return ClientCallWithListener(this, listener) +} + +operator fun ClientCall.Listener.plus(other: ClientCall.Listener): ClientCall.Listener { + return ClientCallMergedListener(this, other) +} + +private class ClientCallWithHeader(delegate: ClientCall, private val metadata: Metadata) : ForwardingClientCall.SimpleForwardingClientCall(delegate) { + override fun start(responseListener: Listener, headers: Metadata) { + headers.merge(metadata) + super.start(responseListener, headers) + } +} + +private class ClientCallWithListener(delegate: ClientCall, private val listener: Listener) : ForwardingClientCall.SimpleForwardingClientCall(delegate) { + override fun start(responseListener: Listener, headers: Metadata) { + super.start(listener + responseListener, headers) + } +} + +private class ClientCallMergedListener(delegate: ClientCall.Listener, private val delegate2: ClientCall.Listener) : ForwardingClientCallListener.SimpleForwardingClientCallListener(delegate) { + override fun onHeaders(headers: Metadata) { + super.onHeaders(headers) + delegate2.onHeaders(headers) + } + + override fun onClose(status: Status, trailers: Metadata) { + super.onClose(status, trailers) + delegate2.onClose(status, trailers) + } + + override fun onMessage(message: RespT) { + super.onMessage(message) + delegate2.onMessage(message) + } + + override fun onReady() { + super.onReady() + delegate2.onReady() + } +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/Debug.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/Debug.kt new file mode 100644 index 00000000..015d97ee --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/Debug.kt @@ -0,0 +1,24 @@ +package com.bybutter.sisyphus.rpc + +import com.bybutter.sisyphus.protobuf.Message +import io.grpc.Context + +val DEBUG_INFO_KEY: Context.Key>> = Context.key("sisyphus-debug-context") + +fun debug(message: Message<*, *>) { + DEBUG_INFO_KEY.get()?.add(message) +} + +inline fun debug(block: () -> Message<*, *>) { + DEBUG_INFO_KEY.get()?.let { + it += block() + } +} + +fun initDebug(context: Context): Context { + return context.withValue(DEBUG_INFO_KEY, mutableListOf()) +} + +val debugEnabled: Boolean get() = DEBUG_INFO_KEY.get() != null + +val debugInfo: List> get() = DEBUG_INFO_KEY.get() ?: listOf() diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/GrpcContextCoroutineContextElement.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/GrpcContextCoroutineContextElement.kt new file mode 100644 index 00000000..7d08fe47 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/GrpcContextCoroutineContextElement.kt @@ -0,0 +1,20 @@ +package com.bybutter.sisyphus.rpc + +import io.grpc.Context +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.ThreadContextElement + +class GrpcContextCoroutineContextElement : ThreadContextElement { + companion object Key : CoroutineContext.Key + + private val grpcContext: Context = Context.current() + + override val key: CoroutineContext.Key + get() = Key + + override fun updateThreadContext(context: CoroutineContext): Context = + grpcContext.attach() + + override fun restoreThreadContext(context: CoroutineContext, oldState: Context) = + grpcContext.detach(oldState) +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/ManyToManyCall.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/ManyToManyCall.kt new file mode 100644 index 00000000..b293cfa3 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/ManyToManyCall.kt @@ -0,0 +1,10 @@ +package com.bybutter.sisyphus.rpc + +import io.grpc.stub.StreamObserver +import kotlinx.coroutines.channels.ReceiveChannel + +class ManyToManyCall( + private val request: StreamObserver, + private val response: ReceiveChannel +) : Sender by request.asSender(), + ReceiveChannel by response diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/ManyToOneCall.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/ManyToOneCall.kt new file mode 100644 index 00000000..40cabce5 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/ManyToOneCall.kt @@ -0,0 +1,10 @@ +package com.bybutter.sisyphus.rpc + +import io.grpc.stub.StreamObserver +import kotlinx.coroutines.Deferred + +class ManyToOneCall( + private val request: StreamObserver, + private val response: Deferred +) : Sender by request.asSender(), + Deferred by response diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/RpcBound.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/RpcBound.kt new file mode 100644 index 00000000..b945ded2 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/RpcBound.kt @@ -0,0 +1,6 @@ +package com.bybutter.sisyphus.rpc + +annotation class RpcBound( + val type: String, + val streaming: Boolean = false +) diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/RpcClient.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/RpcClient.kt new file mode 100644 index 00000000..5379cf2e --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/RpcClient.kt @@ -0,0 +1,7 @@ +package com.bybutter.sisyphus.rpc + +import java.lang.annotation.Inherited + +@Inherited +@Target(AnnotationTarget.CLASS) +annotation class RpcClient(val parent: String, val value: String) diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/RpcMethod.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/RpcMethod.kt new file mode 100644 index 00000000..aca9ceaa --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/RpcMethod.kt @@ -0,0 +1,11 @@ +package com.bybutter.sisyphus.rpc + +import java.lang.annotation.Inherited + +@Inherited +@Target(AnnotationTarget.FUNCTION) +annotation class RpcMethod( + val name: String, + val input: RpcBound, + val output: RpcBound +) diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/RpcService.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/RpcService.kt new file mode 100644 index 00000000..de0ea3ee --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/RpcService.kt @@ -0,0 +1,8 @@ +package com.bybutter.sisyphus.rpc + +import java.lang.annotation.Inherited +import kotlin.reflect.KClass + +@Inherited +@Target(AnnotationTarget.CLASS) +annotation class RpcService(val parent: String, val value: String, val client: KClass<*>) diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/Sender.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/Sender.kt new file mode 100644 index 00000000..1d0d7801 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/Sender.kt @@ -0,0 +1,7 @@ +package com.bybutter.sisyphus.rpc + +interface Sender { + fun send(element: T) + + fun close(cause: Throwable? = null) +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/ServerCallHandlers.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/ServerCallHandlers.kt new file mode 100644 index 00000000..73f1e890 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/ServerCallHandlers.kt @@ -0,0 +1,70 @@ +package com.bybutter.sisyphus.rpc + +import io.grpc.ServerCallHandler +import io.grpc.stub.ServerCalls +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.launch + +object ServerCallHandlers { + fun asyncUnaryCall(block: suspend (TRequest) -> TResponse): ServerCallHandler { + return ServerCalls.asyncUnaryCall { request, responseObserver -> + GlobalScope.launch(GrpcContextCoroutineContextElement()) { + try { + responseObserver.onNext(block(request)) + responseObserver.onCompleted() + } catch (e: Throwable) { + responseObserver.onError(e) + } + } + } + } + + fun asyncClientStreamingCall(block: suspend (ReceiveChannel) -> TResponse): ServerCallHandler { + return ServerCalls.asyncClientStreamingCall { + val request = Channel(Channel.UNLIMITED) + GlobalScope.launch(GrpcContextCoroutineContextElement()) { + try { + it.onNext(block(request)) + it.onCompleted() + } catch (e: Throwable) { + it.onError(e) + } + } + request.asStreamObserver() + } + } + + fun asyncServerStreamingCall(block: suspend (TRequest) -> ReceiveChannel): ServerCallHandler { + return ServerCalls.asyncServerStreamingCall { request, responseObserver -> + GlobalScope.launch(GrpcContextCoroutineContextElement()) { + try { + for (item in block(request)) { + responseObserver.onNext(item) + } + responseObserver.onCompleted() + } catch (e: Throwable) { + responseObserver.onError(e) + } + } + } + } + + fun asyncBidiStreamingCall(block: suspend (ReceiveChannel) -> ReceiveChannel): ServerCallHandler { + return ServerCalls.asyncBidiStreamingCall { + val request = Channel(Channel.UNLIMITED) + GlobalScope.launch(GrpcContextCoroutineContextElement()) { + try { + for (item in block(request)) { + it.onNext(item) + } + it.onCompleted() + } catch (e: Throwable) { + it.onError(e) + } + } + request.asStreamObserver() + } + } +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/ServiceSupport.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/ServiceSupport.kt new file mode 100644 index 00000000..a9b6209d --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/ServiceSupport.kt @@ -0,0 +1,7 @@ +package com.bybutter.sisyphus.rpc + +import com.bybutter.sisyphus.protobuf.primitives.ServiceDescriptorProto + +abstract class ServiceSupport { + abstract val descriptor: ServiceDescriptorProto +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/StatusException.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/StatusException.kt new file mode 100644 index 00000000..2bab5191 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/StatusException.kt @@ -0,0 +1,127 @@ +package com.bybutter.sisyphus.rpc + +import com.bybutter.sisyphus.protobuf.Message +import com.bybutter.sisyphus.protobuf.primitives.Duration +import io.grpc.Metadata +import java.util.concurrent.TimeUnit + +open class StatusException : RuntimeException { + val status: Status + get() { + return Status { + code = this@StatusException.code + this@StatusException.message?.let { message = it } + details += this@StatusException.details.toList() + } + } + + val trailers: Metadata = Metadata() + + private var code = 0 + + private val details = mutableListOf>() + + constructor(code: Code, cause: Throwable) : this(code, cause.message, cause) + + constructor(code: Code, message: String? = null, cause: Throwable? = null) : super(message ?: code.name, cause) { + this.code = code.number + } + + constructor(status: Status) : super(status.message) { + this.code = status.code + this.details += status.details + } + + fun withLocalizedMessage(locale: String, message: String): StatusException { + details.add(LocalizedMessage { + this.locale = locale + this.message = message + }) + return this + } + + fun withLocalizedMessage(message: String): StatusException { + withLocalizedMessage("zh-CN", message) + return this + } + + fun withHelps(vararg links: Help.Link): StatusException { + details.add(Help { + this.links += links.toList() + }) + return this + } + + fun withResourceInfo(resourceType: String, resourceName: String, description: String, owner: String = ""): StatusException { + details.add(ResourceInfo { + this.resourceType = resourceType + this.resourceName = resourceName + this.description = description + this.owner = owner + }) + return this + } + + fun withRequestInfo(requestId: String, servingData: String = ""): StatusException { + details.add(RequestInfo { + this.requestId = requestId + this.servingData = servingData + }) + return this + } + + fun withBadRequest(vararg violations: BadRequest.FieldViolation): StatusException { + details.add(BadRequest { + this.fieldViolations += violations.toList() + }) + return this + } + + fun withPreconditionFailure(vararg violations: PreconditionFailure.Violation): StatusException { + details.add(PreconditionFailure { + this.violations += violations.toList() + }) + return this + } + + fun withQuotaFailure(vararg violations: QuotaFailure.Violation): StatusException { + details.add(QuotaFailure { + this.violations += violations.toList() + }) + return this + } + + fun withRetryInfo(retryDelay: Duration): StatusException { + details.add(RetryInfo { + this.retryDelay = retryDelay + }) + return this + } + + fun withRetryInfo(number: Long, unit: TimeUnit = TimeUnit.SECONDS): StatusException { + withRetryInfo(Duration { + seconds = unit.toSeconds(number) + nanos = (unit.toNanos(number) - TimeUnit.SECONDS.toNanos(seconds)).toInt() + }) + return this + } + + fun withDetails(message: Message<*, *>): StatusException { + details.add(message) + return this + } + + fun withTrailer(key: Metadata.Key, value: T): StatusException { + trailers.put(key, value) + return this + } + + fun withTrailers(trailers: Metadata): StatusException { + this.trailers.merge(trailers) + return this + } +} + +open class ClientStatusException(status: io.grpc.Status, val trailers: Metadata) : RuntimeException(status.description, status.cause) { + val status: Status = trailers[STATUS_META_KEY] ?: Status.fromGrpcStatus(status) +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/StatusExtension.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/StatusExtension.kt new file mode 100644 index 00000000..fcdab2ab --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/rpc/StatusExtension.kt @@ -0,0 +1,78 @@ +package com.bybutter.sisyphus.rpc + +import com.bybutter.sisyphus.protobuf.Message +import com.bybutter.sisyphus.protobuf.invoke +import io.grpc.Metadata +import io.grpc.StatusException +import io.grpc.StatusRuntimeException + +val STATUS_META_KEY: Metadata.Key = Metadata.Key.of("grpc-status-details-bin", Status) + +fun Status.Companion.fromThrowable(e: Throwable): Status { + return when (e) { + is StatusException -> { + e.trailers?.get(STATUS_META_KEY) ?: Status { + code = e.status.code.value() + message = e.status.description ?: "" + extractStatusDetails(details, e) + } + } + is StatusRuntimeException -> { + e.trailers?.get(STATUS_META_KEY) ?: Status { + code = e.status.code.value() + message = e.status.description ?: "" + extractStatusDetails(details, e) + } + } + is com.bybutter.sisyphus.rpc.StatusException -> e.status { + extractStatusDetails(details, e) + } + else -> Status { + code = io.grpc.Status.Code.INTERNAL.value() + message = e.message ?: "" + extractStatusDetails(details, e) + } + } +} + +fun Status.Companion.fromGrpcStatus(status: io.grpc.Status): Status { + return Status { + val cause = status.cause + when (cause) { + is com.bybutter.sisyphus.rpc.StatusException -> { + code = cause.status.code + message = cause.status.message + details += cause.status.details + } + null -> { + code = status.code.value() + message = status.description ?: "Uncaught unknown internal exception occurred." + } + else -> { + code = status.code.value() + message = cause.message ?: "Uncaught '${cause.javaClass.canonicalName}' exception occurred." + } + } + extractStatusDetails(details, status.cause) + } +} + +fun Status.toGrpcStatus(cause: Throwable? = null): io.grpc.Status { + return io.grpc.Status.fromCodeValue(this.code).withDescription(this.message).withCause(cause) +} + +private fun extractStatusDetails(details: MutableList>, throwable: Throwable? = null) { + details += debugInfo + throwable?.extractStatusDetails(details) +} + +private fun Throwable.extractStatusDetails(list: MutableList>) { + if (debugEnabled) { + list += DebugInfo { + detail = "${this@extractStatusDetails.javaClass}(${this@extractStatusDetails.message})" + stackEntries += this@extractStatusDetails.stackTrace.map { it.toString() } + } + } + + cause?.extractStatusDetails(list) +} diff --git a/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/type/MoneyExtension.kt b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/type/MoneyExtension.kt new file mode 100644 index 00000000..da011c38 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/kotlin/com/bybutter/sisyphus/type/MoneyExtension.kt @@ -0,0 +1,150 @@ +package com.bybutter.sisyphus.type + +import com.bybutter.sisyphus.jackson.parseJson +import com.bybutter.sisyphus.protobuf.invoke +import java.math.BigDecimal +import java.util.Currency +import kotlin.math.abs +import kotlin.math.sign +import proto.internal.com.bybutter.sisyphus.type.MutableMoney + +private const val nanosPerUnit = 1000000000L + +val Money.Companion.STD get() = "STD" + +/** + * Create money based on STD currency, 1 STD = 0.01 CNY. + */ +fun Money.Companion.fromSTD(std: Long): Money { + return Money { + currencyCode = Money.STD + units = std + nanos = 0 + } +} + +fun Money.Companion.supportCurrency(code: String): Boolean { + return ExchangeRate.current.rates.containsKey(code) +} + +/** + * Convert money to specified currency. + */ +fun Money.convertTo(currencyCode: String, fallbackCurrencyCode: String = ""): Money { + if (this.currencyCode == currencyCode) return this + + val targetRate = ExchangeRate.current.rates[currencyCode] + ?: if (fallbackCurrencyCode.isEmpty()) { + throw UnsupportedOperationException("Unsupported currency code '${this.currencyCode}'.") + } else { + return convertTo(fallbackCurrencyCode) + } + val currentRate = ExchangeRate.current.rates[this.currencyCode] + ?: throw UnsupportedOperationException("Unsupported currency code '${this.currencyCode}'.") + + val rate = targetRate / currentRate + + val current = BigDecimal.valueOf(this.units) * BigDecimal.valueOf(nanosPerUnit) + BigDecimal.valueOf(this.nanos.toLong()) + val target = current * BigDecimal.valueOf(rate) + + return Money { + this.currencyCode = currencyCode + this.units = (target / BigDecimal.valueOf(nanosPerUnit)).toLong() + this.nanos = (target % BigDecimal.valueOf(nanosPerUnit)).toInt() + } +} + +fun Money.toLocalizedString(): String { + val money = this.units + 1.0 * this.nanos / nanosPerUnit + val currency = Currency.getInstance(this.currencyCode) + return "${currency.symbol} ${String.format("%.2f", money)}" +} + +operator fun Money.plus(other: Money): Money { + val other = if (other.currencyCode != this.currencyCode) { + other.convertTo(this.currencyCode) + } else { + other + } + + return this { + units += other.units + nanos += other.nanos + normalized() + } +} + +operator fun Money.minus(other: Money): Money { + val other = if (other.currencyCode != this.currencyCode) { + other.convertTo(this.currencyCode) + } else { + other + } + + return this { + units -= other.units + nanos -= other.nanos + normalized() + } +} + +operator fun Money.unaryPlus(): Money { + return this {} +} + +operator fun Money.unaryMinus(): Money { + return this { + normalized() + units = -units + nanos = -nanos + } +} + +operator fun Money.compareTo(other: Money): Int { + val other = if (other.currencyCode != this.currencyCode) { + other.convertTo(this.currencyCode) + } else { + other + } + + if (this.units != other.units) { + return this.units.compareTo(other.units) + } + + return this.nanos.compareTo(other.nanos) +} + +private fun MutableMoney.normalized() { + if (units.sign == 0 || nanos.sign == 0) { + return + } + + if (units.sign != nanos.sign) { + units += nanos.sign + nanos = ((nanosPerUnit - abs(nanos)) * units.sign).toInt() + } + + if (nanos >= nanosPerUnit) { + units += nanos / nanosPerUnit + nanos %= nanosPerUnit.toInt() + } +} + +private data class ExchangeRate( + val time_last_update_unix: Long, + val time_next_update_unix: Long, + val base_code: String, + val rates: MutableMap +) { + companion object { + val current: ExchangeRate by lazy { + ExchangeRate::class.java.classLoader.getResources("exchangerate.json").asSequence().map { + it.readText().parseJson() + }.maxBy { + it.time_last_update_unix + }?.apply { + rates[Money.STD] = rates.getValue("CNY") * 100 + } ?: throw IllegalStateException("Read exchange rate failed.") + } + } +} diff --git a/lib/sisyphus-grpc/src/main/proto/sisyphus/api/paging/paging.proto b/lib/sisyphus-grpc/src/main/proto/sisyphus/api/paging/paging.proto new file mode 100644 index 00000000..3a99b5d0 --- /dev/null +++ b/lib/sisyphus-grpc/src/main/proto/sisyphus/api/paging/paging.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package bybutter.api.paging; + +option java_package = "com.bybutter.sisyphus.api.paging"; +option objc_class_prefix = "SIS"; + +message NameAnchorPaging { + string name = 1; +} + +message OffsetPaging { + int32 offset = 1; +} \ No newline at end of file diff --git a/lib/sisyphus-grpc/src/main/proto/sisyphus/api/service_meta.proto b/lib/sisyphus-grpc/src/main/proto/sisyphus/api/service_meta.proto new file mode 100644 index 00000000..7ce0a22d --- /dev/null +++ b/lib/sisyphus-grpc/src/main/proto/sisyphus/api/service_meta.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package bybutter.api; + +import "google/protobuf/descriptor.proto"; + +option java_package = "com.bybutter.sisyphus.api"; +option objc_class_prefix = "SIS"; + +message ServiceMetadata { + string name = 1; + repeated string hosts = 2; +} + +extend google.protobuf.ServiceOptions { + ServiceMetadata metadata = 26051; +} \ No newline at end of file diff --git a/lib/sisyphus-grpc/src/main/resources/exchangerate.json b/lib/sisyphus-grpc/src/main/resources/exchangerate.json new file mode 100644 index 00000000..aa988dde --- /dev/null +++ b/lib/sisyphus-grpc/src/main/resources/exchangerate.json @@ -0,0 +1 @@ +{"documentation":"https://www.exchangerate-api.com/docs/free","terms_of_use":"https://www.exchangerate-api.com/terms","time_last_update_unix":1587600391,"time_last_update_utc":"Thu, 23 Apr 2020 00:06:31 +0000","time_next_update_unix":1587687661,"time_next_update_utc":"Fri, 24 Apr 2020 00:21:01 +0000","time_eol_unix":0,"base_code":"USD","rates":{"USD":1,"AED":3.67,"ARS":66.06,"AUD":1.58,"BGN":1.8,"BRL":5.33,"BSD":1,"CAD":1.42,"CHF":0.97,"CLP":859.92,"CNY":7.09,"COP":4105.05,"CZK":25.33,"DKK":6.87,"DOP":54.05,"EGP":15.72,"EUR":0.922,"FJD":2.27,"GBP":0.811,"GTQ":7.71,"HKD":7.75,"HRK":6.97,"HUF":326.32,"IDR":15298.87,"ILS":3.55,"INR":76.56,"ISK":145.72,"JPY":107.73,"KRW":1232.71,"KZT":433.2,"MXN":24.37,"MYR":4.37,"NOK":10.72,"NZD":1.68,"PAB":1,"PEN":3.38,"PHP":50.72,"PKR":160.53,"PLN":4.17,"PYG":6631.23,"RON":4.45,"RUB":76.58,"SAR":3.76,"SEK":10.08,"SGD":1.43,"THB":32.4,"TRY":6.99,"TWD":30.1,"UAH":27.05,"UYU":43.04,"ZAR":18.89}} \ No newline at end of file diff --git a/lib/sisyphus-jackson/build.gradle.kts b/lib/sisyphus-jackson/build.gradle.kts new file mode 100644 index 00000000..a3c3c79b --- /dev/null +++ b/lib/sisyphus-jackson/build.gradle.kts @@ -0,0 +1,16 @@ +lib + +plugins { + `java-library` +} + +dependencies { + api(project(":lib:sisyphus-common")) + api(Dependencies.Jackson.Module.kotlin) + api(Dependencies.Jackson.Dataformat.yaml) + compileOnly(Dependencies.Jackson.Dataformat.cbor) + compileOnly(Dependencies.Jackson.Dataformat.smile) + compileOnly(Dependencies.Jackson.Dataformat.properties) + + implementation(Dependencies.Kotlin.reflect) +} diff --git a/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Cbor.kt b/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Cbor.kt new file mode 100644 index 00000000..2b47c32e --- /dev/null +++ b/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Cbor.kt @@ -0,0 +1,18 @@ +package com.bybutter.sisyphus.jackson + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.cbor.CBORFactory + +object Cbor : JacksonFormatSupport() { + override val mapper: ObjectMapper by lazy { + ObjectMapper(CBORFactory()).findAndRegisterModules() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(JsonParser.Feature.IGNORE_UNDEFINED, true) + .configure(JsonGenerator.Feature.IGNORE_UNKNOWN, true) + } +} diff --git a/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Extensions.kt b/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Extensions.kt new file mode 100644 index 00000000..908a7f9f --- /dev/null +++ b/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Extensions.kt @@ -0,0 +1,35 @@ +package com.bybutter.sisyphus.jackson + +import com.fasterxml.jackson.databind.BeanDescription +import com.fasterxml.jackson.databind.JavaType +import com.fasterxml.jackson.databind.type.TypeFactory +import java.lang.reflect.Type +import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.reflect.KTypeProjection +import kotlin.reflect.full.createType + +val JavaType.beanDescription: BeanDescription + get() = Json.mapper.deserializationConfig.introspect(this) + +val Class<*>.javaType: JavaType + get() { + return TypeFactory.defaultInstance().constructType(this) + } +val KClass<*>.javaType: JavaType + get() { + return this.java.javaType + } +val Type.javaType: JavaType + get() { + return TypeFactory.defaultInstance().constructType(this) + } +val T.javaType: JavaType + get() { + return TypeFactory.defaultInstance().constructType(this.javaClass) + } +val JavaType.kotlinType: KType + get() { + val parameters = this.findTypeParameters(this.rawClass) + return this.rawClass.kotlin.createType(parameters.map { KTypeProjection.invariant(it.kotlinType) }) + } diff --git a/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/JacksonFormatSupport.kt b/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/JacksonFormatSupport.kt new file mode 100644 index 00000000..83e17c2f --- /dev/null +++ b/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/JacksonFormatSupport.kt @@ -0,0 +1,88 @@ +package com.bybutter.sisyphus.jackson + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.TreeNode +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.JavaType +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.type.TypeFactory +import java.io.InputStream +import java.io.Reader +import java.lang.reflect.Type + +abstract class JacksonFormatSupport { + abstract val mapper: ObjectMapper + + fun deserialize(json: String, type: JavaType): T { + return mapper.readValue(json, type) + } + + fun deserialize(json: String, type: Type): T { + return deserialize(json, mapper.constructType(type)) + } + + fun deserialize(json: String, type: Class): T { + return deserialize(json, TypeFactory.defaultInstance().constructType(type)) + } + + fun deserialize(json: String, type: TypeReference): T { + return deserialize(json, TypeFactory.defaultInstance().constructType(type)) + } + + fun deserialize(jsonReader: Reader, type: JavaType): T { + return mapper.readValue(jsonReader, type) + } + + fun deserialize(jsonReader: Reader, type: Type): T { + return deserialize(jsonReader, mapper.constructType(type)) + } + + fun deserialize(jsonReader: Reader, type: Class): T { + return deserialize(jsonReader, TypeFactory.defaultInstance().constructType(type)) + } + + fun deserialize(jsonReader: Reader, type: TypeReference): T { + return deserialize(jsonReader, TypeFactory.defaultInstance().constructType(type)) + } + + fun deserialize(stream: InputStream, type: JavaType): T { + return mapper.readValue(stream, type) + } + + fun deserialize(stream: InputStream, type: Type): T { + return deserialize(stream, mapper.constructType(type)) + } + + fun deserialize(stream: InputStream, type: Class): T { + return deserialize(stream, TypeFactory.defaultInstance().constructType(type)) + } + + fun deserialize(stream: InputStream, type: TypeReference): T { + return deserialize(stream, TypeFactory.defaultInstance().constructType(type)) + } + + fun deserialize(json: String): JsonNode { + return mapper.readTree(json) + } + + fun deserialize(jsonParser: JsonParser): JsonNode { + return mapper.readTree(jsonParser) + } + + fun deserialize(jsonReader: Reader): JsonNode { + return mapper.readTree(jsonReader) + } + + fun deserialize(stream: InputStream): JsonNode { + return mapper.readTree(stream) + } + + fun serialize(`object`: Any): String { + return mapper.writeValueAsString(`object`) + } + + fun into(node: TreeNode, type: JavaType): T { + return mapper.readValue(mapper.treeAsTokens(node), type) + } +} diff --git a/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Json.kt b/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Json.kt new file mode 100644 index 00000000..3d5fd90b --- /dev/null +++ b/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Json.kt @@ -0,0 +1,37 @@ +package com.bybutter.sisyphus.jackson + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper + +object Json : JacksonFormatSupport() { + override val mapper: ObjectMapper by lazy { + ObjectMapper().findAndRegisterModules() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(JsonParser.Feature.IGNORE_UNDEFINED, true) + .configure(JsonGenerator.Feature.IGNORE_UNKNOWN, true) + } +} + +inline fun String?.parseJsonOrNull(): T? { + this ?: return null + + return try { + Json.deserialize(this, object : TypeReference() {}) + } catch (e: Exception) { + null + } +} + +inline fun String?.parseJsonOrDefault(value: T): T { + return this.parseJsonOrNull() ?: value +} + +inline fun String.parseJson(): T = + Json.deserialize(this, object : TypeReference() {}) + +fun Any.toJson(): String = Json.serialize(this) diff --git a/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Properties.kt b/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Properties.kt new file mode 100644 index 00000000..38957bcf --- /dev/null +++ b/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Properties.kt @@ -0,0 +1,18 @@ +package com.bybutter.sisyphus.jackson + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.javaprop.JavaPropsFactory + +object Properties : JacksonFormatSupport() { + override val mapper: ObjectMapper by lazy { + ObjectMapper(JavaPropsFactory()).findAndRegisterModules() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(JsonParser.Feature.IGNORE_UNDEFINED, true) + .configure(JsonGenerator.Feature.IGNORE_UNKNOWN, true) + } +} diff --git a/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Smile.kt b/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Smile.kt new file mode 100644 index 00000000..6bdf6413 --- /dev/null +++ b/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Smile.kt @@ -0,0 +1,18 @@ +package com.bybutter.sisyphus.jackson + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.smile.SmileFactory + +object Smile : JacksonFormatSupport() { + override val mapper: ObjectMapper by lazy { + ObjectMapper(SmileFactory()).findAndRegisterModules() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(JsonParser.Feature.IGNORE_UNDEFINED, true) + .configure(JsonGenerator.Feature.IGNORE_UNKNOWN, true) + } +} diff --git a/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Yaml.kt b/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Yaml.kt new file mode 100644 index 00000000..bb72fb00 --- /dev/null +++ b/lib/sisyphus-jackson/src/main/kotlin/com/bybutter/sisyphus/jackson/Yaml.kt @@ -0,0 +1,38 @@ +package com.bybutter.sisyphus.jackson + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory + +object Yaml : JacksonFormatSupport() { + override val mapper: ObjectMapper by lazy { + ObjectMapper(YAMLFactory()).findAndRegisterModules() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(JsonParser.Feature.IGNORE_UNDEFINED, true) + .configure(JsonGenerator.Feature.IGNORE_UNKNOWN, true) + } +} + +inline fun String?.parseYamlOrNull(): T? { + this ?: return null + + return try { + Yaml.deserialize(this, object : TypeReference() {}) + } catch (e: Exception) { + null + } +} + +inline fun String?.parseYamlOrDefault(value: T): T { + return this.parseYamlOrNull() ?: value +} + +inline fun String.parseYaml(): T = + Yaml.deserialize(this, object : TypeReference() {}) + +fun Any.toYaml(): String = Yaml.serialize(this) diff --git a/lib/sisyphus-protobuf/build.gradle.kts b/lib/sisyphus-protobuf/build.gradle.kts new file mode 100644 index 00000000..27083290 --- /dev/null +++ b/lib/sisyphus-protobuf/build.gradle.kts @@ -0,0 +1,26 @@ +lib + +plugins { + `java-library` + protobuf +} + +dependencies { + implementation(project(":lib:sisyphus-jackson")) + api(project(":lib:sisyphus-common")) + + implementation(Dependencies.Grpc.proto) + implementation(Dependencies.Kotlin.Coroutines.guava) + api(Dependencies.Grpc.stub) + api(Dependencies.Kotlin.Coroutines.reactor) + api(Dependencies.Proto.base) + + proto(Dependencies.Proto.runtimeProto) +} + +protobuf { + packageMapping( + "google.protobuf" to "com.bybutter.sisyphus.protobuf.primitives", + "google.protobuf.compiler" to "com.bybutter.sisyphus.protobuf.compiler" + ) +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/AbstractMessage.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/AbstractMessage.kt new file mode 100644 index 00000000..65dd4398 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/AbstractMessage.kt @@ -0,0 +1,280 @@ +package com.bybutter.sisyphus.protobuf + +import com.bybutter.sisyphus.collection.contentEquals +import com.bybutter.sisyphus.collection.firstNotNull +import com.bybutter.sisyphus.protobuf.primitives.DescriptorProto +import com.bybutter.sisyphus.protobuf.primitives.FieldDescriptorProto +import com.google.protobuf.CodedOutputStream +import java.io.ByteArrayOutputStream +import java.io.OutputStream +import kotlin.reflect.KProperty + +abstract class AbstractMessage, TM : MutableMessage>( + protected val _support: ProtoSupport +) : Message { + init { + // Initialize [ProtoTypes] class to read all proto info + ProtoTypes + } + + private var sizeCache: Int = -1 + private var hashCodeCache: Int = 0 + + private val _unknownFields = UnknownFields() + + @OptIn(InternalProtoApi::class) + protected val _extensions = hashMapOf>() + get() { + if (field.size < _support.extensions.size) { + for (extension in _support.extensions) { + if (!field.containsKey(extension.fullName)) { + field[extension.fullName] = _unknownFields.exportExtension(extension) as AbstractMutableMessage<*, *> + } + } + } + return field + } + + override fun fieldDescriptors(): List { + return _support.fieldDescriptors + } + + override fun fieldDescriptor(fieldName: String): FieldDescriptorProto { + return _support.fieldInfo(fieldName) + ?: _extensions.values.firstNotNull { it.fieldInfo(fieldName) } + ?: throw IllegalArgumentException("Message not contains field definition of '$fieldName'.") + } + + override fun fieldDescriptor(fieldNumber: Int): FieldDescriptorProto { + return _support.fieldInfo(fieldNumber) + ?: _extensions.values.firstNotNull { it.fieldInfo(fieldNumber) } + ?: throw IllegalArgumentException("Message not contains field definition of '$fieldNumber'.") + } + + fun invalidCache() { + sizeCache = -1 + hashCodeCache = 0 + } + + override fun iterator(): Iterator> { + return MessageIterator(this) + } + + override fun descriptor(): DescriptorProto { + return _support.descriptor + } + + override fun support(): ProtoSupport { + return _support + } + + override fun size(): Int { + if (sizeCache >= 0) { + return sizeCache + } + + sizeCache = computeSize() + _extensions.values.sumBy { it.size() } + unknownFields().size + return sizeCache + } + + override fun hashCode(): Int { + if (hashCodeCache != 0) { + return hashCodeCache + } + + var result = computeHashCode() + for (extension in _extensions) { + result = result * 43 + extension.hashCode() + } + + result = result * 57 + unknownFields().hashCode() + hashCodeCache = result + return result + } + + override fun equals(other: Any?): Boolean { + other ?: return false + if (javaClass != other.javaClass) return false + + val message = other as AbstractMessage<*, *> + + return equals(other as T) && _extensions.contentEquals(message._extensions) && unknownFields() == message.unknownFields() + } + + override fun type(): String { + return _support.fullName + } + + override fun typeUrl(): String { + return "types.bybutter.com/${_support.fullName}" + } + + override fun toProto(): ByteArray { + return ByteArrayOutputStream().use { + val coded = CodedOutputStream.newInstance(it) + writeTo(coded) + coded.flush() + it.toByteArray() + } + } + + override fun clone(): T { + return invoke { } + } + + override fun get(fieldName: String): T { + if (!fieldName.contains('.')) { + return getFieldInCurrent(fieldName) + } + + var target: Any? = this + for (field in fieldName.split('.')) { + val current = target ?: return null as T + + target = when (current) { + is AbstractMessage<*, *> -> current.getFieldInCurrent(field) + is Map<*, *> -> current[field] + else -> throw IllegalStateException("Nested property must be message") + } + } + return target as T + } + + override fun has(fieldName: String): Boolean { + if (!fieldName.contains('.')) { + return hasFieldInCurrent(fieldName) + } + + var target: Any? = this + val fieldPart = fieldName.split('.') + + for (field in fieldPart) { + val current = target ?: return false + + target = when (current) { + is AbstractMessage<*, *> -> { + if (!current.hasFieldInCurrent(field)) { + return false + } + current.getFieldInCurrent(field) + } + is Map<*, *> -> { + if (!current.contains(field)) { + return false + } + current[field] + } + else -> throw IllegalStateException("Nested property must be message") + } + } + return true + } + + protected abstract fun computeSize(): Int + + protected abstract fun computeHashCode(): Int + + protected abstract fun equals(other: T): Boolean + + protected abstract fun writeFields(output: CodedOutputStream) + + fun fieldInfo(name: String): FieldDescriptorProto? { + return _support.fieldInfo(name) + } + + fun fieldInfo(number: Int): FieldDescriptorProto? { + return _support.fieldInfo(number) + } + + override fun writeTo(output: OutputStream) { + val coded = CodedOutputStream.newInstance(output) + writeTo(coded) + coded.flush() + } + + override fun writeTo(output: CodedOutputStream) { + writeFields(output) + for ((_, extension) in _extensions) { + extension.writeTo(output) + } + unknownFields().writeTo(output) + } + + override fun writeDelimitedTo(output: OutputStream) { + val coded = CodedOutputStream.newInstance(output) + writeDelimitedTo(coded) + coded.flush() + } + + override fun writeDelimitedTo(output: CodedOutputStream) { + output.writeInt32NoTag(size()) + writeTo(output) + } + + protected abstract fun getFieldInCurrent(fieldName: String): T + + protected abstract fun hasFieldInCurrent(fieldName: String): Boolean + + protected fun getFieldInExtensions(name: String): T { + val extension = + _extensions.values.firstOrNull { it.fieldInfo(name) != null } + ?: throw IllegalArgumentException("Message not contains field definition of '$name'.") + + return extension[name] + } + + protected fun getFieldInExtensions(number: Int): T { + val extension = + _extensions.values.firstOrNull { it.fieldInfo(number) != null } + ?: throw IllegalArgumentException("Message not contains field definition of '$number'.") + + return extension[number] + } + + protected fun getPropertyInExtensions(name: String): KProperty<*>? { + val extension = + _extensions.values.firstOrNull { it.fieldInfo(name) != null } ?: return null + + return extension.getProperty(name) + } + + protected fun getPropertyInExtensions(number: Int): KProperty<*>? { + val extension = + _extensions.values.firstOrNull { it.fieldInfo(number) != null } ?: return null + + return extension.getProperty(number) + } + + protected fun hasFieldInExtensions(name: String): Boolean { + val extension = + _extensions.values.firstOrNull { it.fieldInfo(name) != null } ?: return false + + return extension.has(name) + } + + protected fun hasFieldInExtensions(number: Int): Boolean { + val extension = + _extensions.values.firstOrNull { it.fieldInfo(number) != null } ?: return false + + return extension.has(number) + } + + override fun unknownFields(): UnknownFields { + return _unknownFields + } +} + +private class MessageIterator(private val message: Message<*, *>) : Iterator> { + private val fieldIterator = message.fieldDescriptors().iterator() + + override fun hasNext(): Boolean { + return fieldIterator.hasNext() + } + + override fun next(): Pair { + val field = fieldIterator.next() + val value: Any? = message[field.number] + + return field to value + } +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/AbstractMutableMessage.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/AbstractMutableMessage.kt new file mode 100644 index 00000000..d414cbc5 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/AbstractMutableMessage.kt @@ -0,0 +1,112 @@ +package com.bybutter.sisyphus.protobuf + +import com.google.protobuf.CodedInputStream +import com.google.protobuf.WireFormat + +@OptIn(ExperimentalUnsignedTypes::class) +abstract class AbstractMutableMessage, TM : MutableMessage>( + support: ProtoSupport +) : AbstractMessage(support), MutableMessage { + + abstract fun readField(input: CodedInputStream, field: Int, wire: Int): Boolean + + override fun readFrom(input: CodedInputStream, size: Int) { + val current = input.totalBytesRead + while (!input.isAtEnd && input.totalBytesRead - current < size) { + val tag = input.readTag() + val number = WireFormat.getTagFieldNumber(tag) + val wireType = WireFormat.getTagWireType(tag) + if (!readField(input, number, wireType)) { + if (!_extensions.values.any { it.readField(input, number, wireType) }) { + unknownFields().readFrom(input, number, wireType) + } + } + } + + if (size != Int.MAX_VALUE && input.totalBytesRead - current != size) { + throw IllegalStateException("Wrong message data at position $current with length $size.") + } + } + + override fun set(fieldName: String, value: T) { + if (!fieldName.contains('.')) { + return setFieldInCurrent(fieldName, value) + } + throw UnsupportedOperationException("Set field not support nested field.") + } + + override fun clear(fieldName: String): Any? { + if (!fieldName.contains('.')) { + return clearFieldInCurrent(fieldName) + } + + var target: Any? = this + val fieldPart = fieldName.split('.') + + for ((index, field) in fieldPart.withIndex()) { + val current = target ?: return null + + target = when (current) { + is AbstractMutableMessage<*, *> -> { + if (index == fieldPart.size - 1) { + return current.clearFieldInCurrent(field) + } + current.getFieldInCurrent(field) + } + is MutableMap<*, *> -> { + if (index == fieldPart.size - 1) { + return current.remove(field) + } + current[field] + } + else -> throw IllegalStateException("Nested property must be message") + } + } + + return null + } + + protected abstract fun setFieldInCurrent(fieldName: String, value: T) + + protected abstract fun clearFieldInCurrent(fieldName: String): Any? + + protected fun setFieldInExtensions(name: String, value: T) { + val extension = + _extensions.values.firstOrNull { it.fieldInfo(name) != null } + ?: throw IllegalArgumentException("Message not contains field definition of '$name'.") + + extension[name] = value + invalidCache() + } + + protected fun setFieldInExtensions(number: Int, value: T) { + val extension = + _extensions.values.firstOrNull { it.fieldInfo(number) != null } + ?: throw IllegalArgumentException("Message not contains field definition of '$number'.") + + extension[number] = value + invalidCache() + } + + protected fun clearFieldInExtensions(name: String): Any? { + val extension = + _extensions.values.firstOrNull { it.fieldInfo(name) != null } ?: return null + + invalidCache() + return extension.clear(name) + } + + protected fun clearFieldInExtensions(number: Int): Any? { + val extension = + _extensions.values.firstOrNull { it.fieldInfo(number) != null } ?: return null + + invalidCache() + return extension.clear(number) + } + + protected fun clearAllFieldInExtensions() { + for ((_, extension) in _extensions) { + extension.clear() + } + } +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/CustomProtoType.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/CustomProtoType.kt new file mode 100644 index 00000000..0e096c72 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/CustomProtoType.kt @@ -0,0 +1,22 @@ +package com.bybutter.sisyphus.protobuf + +/** + * Interface of custom protobuf type, it can be used for map protobuf types to custom types. + * + * For example: Protobuf compiler use it to mapping string fields which has resource name option to ResourceName. + */ +interface CustomProtoType { + /** + * Get the raw protobuf type of current custom proto type. + */ + fun raw(): T +} + +interface CustomProtoTypeSupport, TRaw> { + val rawType: Class + + /** + * Wrap raw protobuf type to custom proto type. + */ + fun wrapRaw(value: TRaw): T +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/EnumSupport.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/EnumSupport.kt new file mode 100644 index 00000000..26d0a641 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/EnumSupport.kt @@ -0,0 +1,16 @@ +package com.bybutter.sisyphus.protobuf + +import com.bybutter.sisyphus.protobuf.primitives.EnumDescriptorProto +import io.grpc.Metadata + +abstract class EnumSupport : ProtoEnumDsl, Metadata.AsciiMarshaller { + abstract val descriptor: EnumDescriptorProto + + override fun toAsciiString(value: T): String { + return value.proto + } + + override fun parseAsciiString(serialized: String): T { + return invoke(serialized) + } +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ExtensionSupport.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ExtensionSupport.kt new file mode 100644 index 00000000..cb7c679b --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ExtensionSupport.kt @@ -0,0 +1,11 @@ +package com.bybutter.sisyphus.protobuf + +import com.bybutter.sisyphus.protobuf.primitives.FieldDescriptorProto +import java.util.UUID + +abstract class ExtensionSupport, TM : MutableMessage> : ProtoSupport(UUID.randomUUID().toString()) { + abstract val extendedFields: List + + override val fieldDescriptors: List + get() = extendedFields +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/InternalProtoApi.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/InternalProtoApi.kt new file mode 100644 index 00000000..d476ee1d --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/InternalProtoApi.kt @@ -0,0 +1,5 @@ +package com.bybutter.sisyphus.protobuf + +@Retention(value = AnnotationRetention.BINARY) +@RequiresOptIn("Just for protobuf runtime internal use only.") +annotation class InternalProtoApi diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/MapEntry.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/MapEntry.kt new file mode 100644 index 00000000..929643dd --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/MapEntry.kt @@ -0,0 +1,13 @@ +package com.bybutter.sisyphus.protobuf + +interface MapEntry, TM : MutableMapEntry> : + Message { + val key: TKey + val value: TValue +} + +interface MutableMapEntry, TM : MutableMapEntry> : + MapEntry, MutableMessage { + override var key: TKey + override var value: TValue +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/MapEntrySupport.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/MapEntrySupport.kt new file mode 100644 index 00000000..72858312 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/MapEntrySupport.kt @@ -0,0 +1,28 @@ +package com.bybutter.sisyphus.protobuf + +import com.google.protobuf.CodedInputStream +import com.google.protobuf.CodedOutputStream +import com.google.protobuf.WireFormat + +abstract class MapEntrySupport, TM : MutableMapEntry>(fullName: String) : ProtoSupport(fullName) { + fun sizeOf(number: Int, value: Map): Int { + return value.entries.sumBy { + val size = sizeOfPair(it) + CodedOutputStream.computeTagSize(number) + CodedOutputStream.computeInt32SizeNoTag(size) + size + } + } + + fun writeMap(output: CodedOutputStream, number: Int, value: Map) { + for (entry in value.entries) { + output.writeTag(number, WireFormat.WIRETYPE_LENGTH_DELIMITED) + output.writeInt32NoTag(sizeOfPair(entry)) + writePair(output, entry) + } + } + + abstract fun sizeOfPair(entry: Map.Entry): Int + + abstract fun writePair(output: CodedOutputStream, entry: Map.Entry) + + abstract fun readPair(input: CodedInputStream, size: Int): Pair +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/Message.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/Message.kt new file mode 100644 index 00000000..90b375c0 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/Message.kt @@ -0,0 +1,136 @@ +package com.bybutter.sisyphus.protobuf + +import com.bybutter.sisyphus.protobuf.primitives.DescriptorProto +import com.bybutter.sisyphus.protobuf.primitives.FieldDescriptorProto +import com.google.protobuf.CodedInputStream +import com.google.protobuf.CodedOutputStream +import java.io.OutputStream +import kotlin.reflect.KProperty + +interface Message, TM : MutableMessage> : Cloneable { + /** + * Get message size in bytes. + */ + fun size(): Int + + /** + * Get message full type name, etc: google.protobuf.Any + */ + fun type(): String + + /** + * Get message type url, etc: https://type.bybutter.com/google.protobuf.Any + */ + fun typeUrl(): String + + /** + * Serialize message to bytes. + */ + fun toProto(): ByteArray + + fun descriptor(): DescriptorProto + + fun fieldDescriptors(): List + + fun fieldDescriptor(fieldName: String): FieldDescriptorProto + + fun fieldDescriptor(fieldNumber: Int): FieldDescriptorProto + + fun support(): ProtoSupport + + operator fun iterator(): Iterator> + + /** + * Get field value by field/json name. + */ + operator fun get(fieldName: String): T + + /** + * Get field value by field number. + */ + operator fun get(fieldNumber: Int): T + + /** + * Get kotlin property info by field/json name. + */ + fun getProperty(fieldName: String): KProperty<*>? + + /** + * Get kotlin property info by field number. + */ + fun getProperty(fieldNumber: Int): KProperty<*>? + + /** + * Check if the message has a specified field, it accords to is field tag contained in the serialized message. + * + * * Required fields always return true. + * * Optional fields without setting any values will return false. + * * Optional fields which have been set value will return true. + * * Repeated fields with the empty list will return false. + * * Repeated fields with a non-empty list will return true. + */ + fun has(fieldName: String): Boolean + + /** + * Check if the message has a specified field, it accords to is field tag contained in the serialized message. + * + * * Required fields always return true. + * * Optional fields without setting any values will return false. + * * Optional fields which have been set value will return true. + * * Repeated fields with the empty list will return false. + * * Repeated fields with a non-empty list will return true. + */ + fun has(fieldNumber: Int): Boolean + + /** + * Merge another message together, it will create a copy for merging. + */ + fun unionOf(other: T?): T + + /** + * Create a shallow copy of message. + */ + override fun clone(): T + + /** + * DO NOT USE IT! It designed for internal use. + * + * Create a shallow mutable copy of message. + */ + @InternalProtoApi + fun cloneMutable(): TM + + fun writeTo(output: OutputStream) + + fun writeTo(output: CodedOutputStream) + + fun writeDelimitedTo(output: OutputStream) + + fun writeDelimitedTo(output: CodedOutputStream) + + fun unknownFields(): UnknownFields +} + +@OptIn(InternalProtoApi::class) +inline operator fun , TM : MutableMessage> Message.invoke(block: TM.() -> Unit): T { + return this.cloneMutable().apply(block) as T +} + +interface MutableMessage, TM : MutableMessage> : Message { + operator fun set(fieldName: String, value: T) + operator fun set(fieldNumber: Int, value: T) + fun clear(fieldName: String): Any? + fun clear(fieldNumber: Int): Any? + + /** + * Clear all optional and repeated fields + */ + fun clear() + + /** + * Merge another message to current mutable message + */ + fun mergeWith(other: T?) + + fun readFrom(input: CodedInputStream, size: Int) +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/MessagePatcher.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/MessagePatcher.kt new file mode 100644 index 00000000..59955b3d --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/MessagePatcher.kt @@ -0,0 +1,145 @@ +package com.bybutter.sisyphus.protobuf + +import com.bybutter.sisyphus.protobuf.primitives.FieldDescriptorProto +import com.bybutter.sisyphus.security.base64Decode +import kotlin.reflect.KClass +import kotlin.reflect.KFunction +import kotlin.reflect.KProperty +import kotlin.reflect.full.companionObjectInstance +import kotlin.reflect.full.isSubclassOf + +interface PatcherNode { + fun asField(field: FieldDescriptorProto, property: KProperty<*>): Any? +} + +class ValueNode : PatcherNode { + private val values = mutableListOf() + + fun add(value: String) { + values.add(value) + } + + fun addAll(value: Iterable) { + values.addAll(value) + } + + override fun asField(field: FieldDescriptorProto, property: KProperty<*>): Any? { + var result = when (field.type) { + FieldDescriptorProto.Type.DOUBLE -> values.map { it.toDouble() } + FieldDescriptorProto.Type.FLOAT -> values.map { it.toFloat() } + FieldDescriptorProto.Type.SINT64, + FieldDescriptorProto.Type.SFIXED64, + FieldDescriptorProto.Type.INT64 -> values.map { it.toLong() } + FieldDescriptorProto.Type.FIXED64, + FieldDescriptorProto.Type.UINT64 -> values.map { it.toULong() } + FieldDescriptorProto.Type.SFIXED32, + FieldDescriptorProto.Type.SINT32, + FieldDescriptorProto.Type.INT32 -> values.map { it.toInt() } + FieldDescriptorProto.Type.UINT32, + FieldDescriptorProto.Type.FIXED32 -> values.map { it.toUInt() } + FieldDescriptorProto.Type.BOOL -> values.map { it.toBoolean() } + FieldDescriptorProto.Type.STRING -> values + FieldDescriptorProto.Type.BYTES -> values.map { it.base64Decode() } + FieldDescriptorProto.Type.ENUM -> values.map { ProtoEnum(it, ProtoTypes.getClassByProtoName(field.typeName) as Class) } + else -> throw IllegalStateException() + } + + val propertyType = property.returnType.classifier as? KClass<*> + if (propertyType != null && propertyType.isSubclassOf(CustomProtoType::class)) { + result = result.map { + val support = (propertyType.companionObjectInstance as CustomProtoTypeSupport<*, Any>) + support.wrapRaw(it) + } + } + + return when (field.label) { + FieldDescriptorProto.Label.OPTIONAL -> result.lastOrNull() + FieldDescriptorProto.Label.REQUIRED -> result.last() + FieldDescriptorProto.Label.REPEATED -> result + else -> throw IllegalStateException() + } + } +} + +class MessagePatcher : PatcherNode { + private val nodes = mutableMapOf() + + fun add(field: String, value: String) { + add(0, field.split('.'), value) + } + + fun addList(field: String, value: List) { + addList(0, field.split('.'), value) + } + + fun addAll(map: Map) { + for ((field, value) in map) { + add(0, field.split('.'), value) + } + } + + fun addAllList(map: Map>) { + for ((field, value) in map) { + addList(0, field.split('.'), value) + } + } + + protected fun add(index: Int, field: List, value: String) { + if (index == field.size - 1) { + val valueNode = nodes.getOrPut(field[index]) { + ValueNode() + } as? ValueNode ?: throw IllegalStateException() + valueNode.add(value) + return + } + + val patcherNode = nodes.getOrPut(field[index]) { + MessagePatcher() + } as? MessagePatcher ?: throw IllegalStateException() + + patcherNode.add(index + 1, field, value) + } + + protected fun addList(index: Int, field: List, value: List) { + if (index == field.size - 1) { + val valueNode = nodes.getOrPut(field[index]) { + ValueNode() + } as? ValueNode ?: throw IllegalStateException() + valueNode.addAll(value) + return + } + + val patcherNode = nodes.getOrPut(field[index]) { + MessagePatcher() + } as? MessagePatcher ?: throw IllegalStateException() + + patcherNode.addList(index + 1, field, value) + } + + fun applyTo(message: MutableMessage<*, *>) { + val function = MutableMessage<*, *>::mergeWith as KFunction + function.call(message, asMessage(message.type())) + } + + @OptIn(InternalProtoApi::class) + override fun asField(field: FieldDescriptorProto, property: KProperty<*>): Any? { + return when (field.type) { + FieldDescriptorProto.Type.MESSAGE -> { + asMessage(field.typeName) + } + else -> throw IllegalStateException() + } + } + + @OptIn(InternalProtoApi::class) + fun asMessage(type: String): Message<*, *> { + return ProtoTypes.ensureSupportByProtoName(type).newMutable().apply { + for ((field, value) in nodes) { + if (this.getProperty(field) == null) { + continue + } + this[field] = value.asField(this.fieldDescriptor(field), this.getProperty(field)!!) + } + } + } +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/OneOfDelegate.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/OneOfDelegate.kt new file mode 100644 index 00000000..eee8898b --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/OneOfDelegate.kt @@ -0,0 +1,15 @@ +package com.bybutter.sisyphus.protobuf + +import kotlin.reflect.KProperty + +interface OneOfDelegate, TM : Message<*, *>> { + var value: T? + + operator fun getValue(ref: TM, property: KProperty<*>): T + + operator fun setValue(ref: TM, property: KProperty<*>, value: T) + + fun clear(ref: TM, property: KProperty<*>): T? + + fun has(ref: TM, property: KProperty<*>): Boolean +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/OneOfValue.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/OneOfValue.kt new file mode 100644 index 00000000..d8719660 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/OneOfValue.kt @@ -0,0 +1,7 @@ +package com.bybutter.sisyphus.protobuf + +interface OneOfValue { + val name: String + val number: Int + val value: T +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoEnum.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoEnum.kt new file mode 100644 index 00000000..84e5bf3a --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoEnum.kt @@ -0,0 +1,120 @@ +package com.bybutter.sisyphus.protobuf + +interface ProtoEnum { + val proto: String + val number: Int + + companion object { + fun fromProto(value: String?, type: Class): T? where T : ProtoEnum { + return type.enumConstants.firstOrNull { it.proto == value } + } + + inline fun fromProto(value: String?): T? where T : ProtoEnum { + return fromProto(value, T::class.java) + } + + operator fun invoke(value: String?, type: Class): T where T : ProtoEnum { + return fromProto(value, type) + ?: invoke(type) + } + + inline operator fun invoke(value: String?): T where T : ProtoEnum { + return invoke(value, T::class.java) + } + + fun fromNumber(value: Int?, type: Class): T? where T : ProtoEnum { + return type.enumConstants.firstOrNull { it.number == value } + } + + inline fun fromNumber(value: Int?): T? where T : ProtoEnum { + return fromNumber(value, T::class.java) + } + + operator fun invoke(value: Int?, type: Class): T where T : ProtoEnum { + return fromNumber(value, type) + ?: invoke(type) + } + + inline operator fun invoke(value: Int?): T where T : ProtoEnum { + return invoke(value, T::class.java) + } + + operator fun invoke(type: Class): T where T : ProtoEnum { + return type.enumConstants.first() + } + + inline operator fun invoke(): T where T : ProtoEnum { + return invoke(T::class.java) + } + } +} + +interface ProtoEnumDsl { + operator fun invoke(value: String): T + + fun fromProto(value: String): T? + + operator fun invoke(value: Int): T + + fun fromNumber(value: Int): T? + + operator fun invoke(): T + + companion object { + operator fun invoke(clazz: Class): ProtoEnumDsl { + return Impl(clazz) + } + } + + private class Impl(val clazz: Class) : ProtoEnumDsl { + override fun invoke(value: String): T { + return ProtoEnum(value, clazz) + } + + override fun fromProto(value: String): T? { + return ProtoEnum.fromProto(value, clazz) + } + + override fun invoke(value: Int): T { + return ProtoEnum(value, clazz) + } + + override fun fromNumber(value: Int): T? { + return ProtoEnum.fromNumber(value, clazz) + } + + override fun invoke(): T { + return ProtoEnum(clazz) + } + } +} + +interface ProtoStringEnum : ProtoEnum { + val value: String + + companion object { + fun fromValue(value: String?, type: Class): T? where T : ProtoStringEnum { + return type.enumConstants.firstOrNull { it.value == value } + } + + inline fun fromValue(value: String?): T? where T : ProtoStringEnum { + return fromValue(value, T::class.java) + } + } +} + +interface ProtoStringEnumDsl : ProtoEnumDsl { + fun fromValue(value: String): T? + + companion object { + operator fun invoke(clazz: Class): ProtoStringEnumDsl { + return Impl(clazz) + } + } + + private class Impl(val clazz: Class) : ProtoStringEnumDsl, ProtoEnumDsl by ProtoEnumDsl(clazz) { + override fun fromValue(value: String): T? { + return ProtoStringEnum.fromValue(value, clazz) + } + } +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoFileMeta.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoFileMeta.kt new file mode 100644 index 00000000..9a719ca3 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoFileMeta.kt @@ -0,0 +1,8 @@ +package com.bybutter.sisyphus.protobuf + +import com.bybutter.sisyphus.protobuf.primitives.FileDescriptorProto + +interface ProtoFileMeta { + val name: String + val descriptor: FileDescriptorProto +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoReader.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoReader.kt new file mode 100644 index 00000000..c9026535 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoReader.kt @@ -0,0 +1,657 @@ +package com.bybutter.sisyphus.protobuf + +import com.bybutter.sisyphus.protobuf.primitives.Any +import com.bybutter.sisyphus.protobuf.primitives.toMessage +import com.google.protobuf.CodedInputStream +import com.google.protobuf.WireFormat +import kotlin.reflect.full.companionObjectInstance + +@OptIn(ExperimentalUnsignedTypes::class) +object ProtoReader { + fun readEnum(input: CodedInputStream, clazz: Class, field: Int, wire: Int): T? { + return when (wire) { + WireFormat.WIRETYPE_VARINT -> { + ProtoEnum(input.readEnum(), clazz) + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val length = input.readInt32() + if (length == 0) return null + var result = 0 + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result = input.readEnum() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + ProtoEnum(result, clazz) + } + else -> throw IllegalStateException("Enum field only accept 'varint' or 'lengthDelimited' data.") + } + } + + fun readEnumList(input: CodedInputStream, clazz: Class, field: Int, wire: Int): List { + return when (wire) { + WireFormat.WIRETYPE_VARINT -> { + listOf(ProtoEnum(input.readEnum(), clazz)) + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val result = mutableListOf() + val length = input.readInt32() + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result += ProtoEnum(input.readEnum(), clazz) + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("Enum field only accept 'varint' or 'lengthDelimited' data.") + } + } + + fun readInt32(input: CodedInputStream, field: Int, wire: Int): Int? { + return when (wire) { + WireFormat.WIRETYPE_VARINT -> { + input.readInt32() + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val length = input.readInt32() + if (length == 0) return null + var result = 0 + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result = input.readInt32() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("Int32 field only accept 'varint' or 'lengthDelimited' data.") + } + } + + fun readInt32List(input: CodedInputStream, field: Int, wire: Int): List { + return when (wire) { + WireFormat.WIRETYPE_VARINT -> { + listOf(input.readInt32()) + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val result = mutableListOf() + val length = input.readInt32() + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result += input.readInt32() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("Int32 field only accept 'varint' or 'lengthDelimited' data.") + } + } + + fun readSInt32(input: CodedInputStream, field: Int, wire: Int): Int? { + return when (wire) { + WireFormat.WIRETYPE_VARINT -> { + input.readSInt32() + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val length = input.readInt32() + if (length == 0) return null + var result = 0 + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result = input.readSInt32() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("Int32 field only accept 'varint' or 'lengthDelimited' data.") + } + } + + fun readSInt32List(input: CodedInputStream, field: Int, wire: Int): List { + return when (wire) { + WireFormat.WIRETYPE_VARINT -> { + listOf(input.readSInt32()) + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val result = mutableListOf() + val length = input.readInt32() + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result += input.readSInt32() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("SInt32 field only accept 'varint' or 'lengthDelimited' data.") + } + } + + fun readUInt32(input: CodedInputStream, field: Int, wire: Int): UInt? { + return when (wire) { + WireFormat.WIRETYPE_VARINT -> { + input.readUInt32().toUInt() + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val length = input.readInt32() + if (length == 0) return null + var result = 0 + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result = input.readUInt32() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result.toUInt() + } + else -> throw IllegalStateException("UInt32 field only accept 'varint' or 'lengthDelimited' data.") + } + } + + fun readUInt32List(input: CodedInputStream, field: Int, wire: Int): List { + return when (wire) { + WireFormat.WIRETYPE_VARINT -> { + listOf(input.readUInt32().toUInt()) + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val result = mutableListOf() + val length = input.readInt32() + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result += input.readUInt32().toUInt() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("UInt32 field only accept 'varint' or 'lengthDelimited' data.") + } + } + + fun readInt64(input: CodedInputStream, field: Int, wire: Int): Long? { + return when (wire) { + WireFormat.WIRETYPE_VARINT -> { + input.readInt64() + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val length = input.readInt32() + if (length == 0) return null + var result = 0L + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result = input.readInt64() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("Int64 field only accept 'varint' or 'lengthDelimited' data.") + } + } + + fun readInt64List(input: CodedInputStream, field: Int, wire: Int): List { + return when (wire) { + WireFormat.WIRETYPE_VARINT -> { + listOf(input.readInt64()) + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val result = mutableListOf() + val length = input.readInt32() + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result += input.readInt64() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("Int64 field only accept 'varint' or 'lengthDelimited' data.") + } + } + + fun readSInt64(input: CodedInputStream, field: Int, wire: Int): Long? { + return when (wire) { + WireFormat.WIRETYPE_VARINT -> { + input.readSInt64() + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val length = input.readInt32() + if (length == 0) return null + var result = 0L + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result = input.readSInt64() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("Int64 field only accept 'varint' or 'lengthDelimited' data.") + } + } + + fun readSInt64List(input: CodedInputStream, field: Int, wire: Int): List { + return when (wire) { + WireFormat.WIRETYPE_VARINT -> { + listOf(input.readSInt64()) + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val result = mutableListOf() + val length = input.readInt32() + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result += input.readSInt64() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("SInt64 field only accept 'varint' or 'lengthDelimited' data.") + } + } + + fun readUInt64(input: CodedInputStream, field: Int, wire: Int): ULong? { + return when (wire) { + WireFormat.WIRETYPE_VARINT -> { + input.readUInt64().toULong() + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val length = input.readInt32() + if (length == 0) return null + var result = 0L + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result = input.readUInt64() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result.toULong() + } + else -> throw IllegalStateException("UInt64 field only accept 'varint' or 'lengthDelimited' data.") + } + } + + fun readUInt64List(input: CodedInputStream, field: Int, wire: Int): List { + return when (wire) { + WireFormat.WIRETYPE_VARINT -> { + listOf(input.readUInt64().toULong()) + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val result = mutableListOf() + val length = input.readInt32() + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result += input.readUInt64().toULong() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("UInt64 field only accept 'varint' or 'lengthDelimited' data.") + } + } + + fun readBool(input: CodedInputStream, field: Int, wire: Int): Boolean? { + return when (wire) { + WireFormat.WIRETYPE_VARINT -> { + input.readBool() + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val length = input.readInt32() + if (length == 0) return null + var result = false + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result = input.readBool() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("Bool field only accept 'varint' or 'lengthDelimited' data.") + } + } + + fun readBoolList(input: CodedInputStream, field: Int, wire: Int): List { + return when (wire) { + WireFormat.WIRETYPE_VARINT -> { + listOf(input.readBool()) + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val result = mutableListOf() + val length = input.readInt32() + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result += input.readBool() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("Bool field only accept 'varint' or 'lengthDelimited' data.") + } + } + + fun readFloat(input: CodedInputStream, field: Int, wire: Int): Float? { + return when (wire) { + WireFormat.WIRETYPE_FIXED32 -> { + input.readFloat() + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val length = input.readInt32() + if (length == 0) return null + var result = 0.0f + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result = input.readFloat() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("Float field only accept 'fixed32' or 'lengthDelimited' data.") + } + } + + fun readFloatList(input: CodedInputStream, field: Int, wire: Int): List { + return when (wire) { + WireFormat.WIRETYPE_FIXED32 -> { + listOf(input.readFloat()) + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val result = mutableListOf() + val length = input.readInt32() + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result += input.readFloat() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("Float field only accept 'fixed32' or 'lengthDelimited' data.") + } + } + + fun readSFixed32(input: CodedInputStream, field: Int, wire: Int): Int? { + return when (wire) { + WireFormat.WIRETYPE_FIXED32 -> { + input.readSFixed32() + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val length = input.readInt32() + if (length == 0) return null + var result = 0 + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result = input.readSFixed32() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("SFixed32 field only accept 'fixed32' or 'lengthDelimited' data.") + } + } + + fun readSFixed32List(input: CodedInputStream, field: Int, wire: Int): List { + return when (wire) { + WireFormat.WIRETYPE_FIXED32 -> { + listOf(input.readSFixed32()) + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val result = mutableListOf() + val length = input.readInt32() + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result += input.readSFixed32() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("SFixed32 field only accept 'fixed32' or 'lengthDelimited' data.") + } + } + + fun readFixed32(input: CodedInputStream, field: Int, wire: Int): UInt? { + return when (wire) { + WireFormat.WIRETYPE_FIXED32 -> { + input.readFixed32().toUInt() + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val length = input.readInt32() + if (length == 0) return null + var result = 0 + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result = input.readFixed32() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result.toUInt() + } + else -> throw IllegalStateException("Fixed32 field only accept 'fixed32' or 'lengthDelimited' data.") + } + } + + fun readFixed32List(input: CodedInputStream, field: Int, wire: Int): List { + return when (wire) { + WireFormat.WIRETYPE_FIXED32 -> { + listOf(input.readFixed32().toUInt()) + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val result = mutableListOf() + val length = input.readInt32() + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result += input.readFixed32().toUInt() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("Fixed32 field only accept 'fixed64' or 'lengthDelimited' data.") + } + } + + fun readDouble(input: CodedInputStream, field: Int, wire: Int): Double? { + return when (wire) { + WireFormat.WIRETYPE_FIXED64 -> { + input.readDouble() + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val length = input.readInt32() + if (length == 0) return null + var result = 0.0 + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result = input.readDouble() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("Double field only accept 'fixed64' or 'lengthDelimited' data.") + } + } + + fun readDoubleList(input: CodedInputStream, field: Int, wire: Int): List { + return when (wire) { + WireFormat.WIRETYPE_FIXED64 -> { + listOf(input.readDouble()) + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val result = mutableListOf() + val length = input.readInt32() + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result += input.readDouble() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("Double field only accept 'fixed64' or 'lengthDelimited' data.") + } + } + + fun readSFixed64(input: CodedInputStream, field: Int, wire: Int): Long? { + return when (wire) { + WireFormat.WIRETYPE_FIXED64 -> { + input.readSFixed64() + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val length = input.readInt32() + if (length == 0) return null + var result = 0L + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result = input.readSFixed64() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("SFixed64 field only accept 'fixed64' or 'lengthDelimited' data.") + } + } + + fun readSFixed64List(input: CodedInputStream, field: Int, wire: Int): List { + return when (wire) { + WireFormat.WIRETYPE_FIXED64 -> { + listOf(input.readSFixed64()) + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val result = mutableListOf() + val length = input.readInt32() + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result += input.readSFixed64() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("SFixed64 field only accept 'fixed64' or 'lengthDelimited' data.") + } + } + + fun readFixed64(input: CodedInputStream, field: Int, wire: Int): ULong? { + return when (wire) { + WireFormat.WIRETYPE_FIXED64 -> { + input.readFixed64().toULong() + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val length = input.readInt32() + if (length == 0) return null + var result = 0L + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result = input.readFixed64() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result.toULong() + } + else -> throw IllegalStateException("Fixed64 field only accept 'fixed64' or 'lengthDelimited' data.") + } + } + + fun readFixed64List(input: CodedInputStream, field: Int, wire: Int): List { + return when (wire) { + WireFormat.WIRETYPE_FIXED64 -> { + listOf(input.readFixed64().toULong()) + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val result = mutableListOf() + val length = input.readInt32() + val current = input.totalBytesRead + while (input.totalBytesRead - current < length) { + result += input.readFixed64().toULong() + } + if (input.totalBytesRead - current != length) { + throw IllegalStateException("Wrong packed data at position $current with length $length.") + } + result + } + else -> throw IllegalStateException("Fixed64 field only accept 'fixed64' or 'lengthDelimited' data.") + } + } + + fun readString(input: CodedInputStream, field: Int, wire: Int): String { + return when (wire) { + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + input.readString() + } + else -> throw IllegalStateException("String field only accept 'lengthDelimited' data.") + } + } + + fun readBytes(input: CodedInputStream, field: Int, wire: Int): ByteArray { + return when (wire) { + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + input.readByteArray() + } + else -> throw IllegalStateException("Bytes field only accept 'lengthDelimited' data.") + } + } + + fun > readMessage(input: CodedInputStream, clazz: Class, field: Int, wire: Int, size: Int): T { + return when (wire) { + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + try { + val support = clazz.kotlin.companionObjectInstance as? ProtoSupport<*, *> + ?: throw UnsupportedOperationException("Message must be generated by proto compiler.") + support.parse(input, size) as T + } catch (e: Exception) { + clazz.kotlin.companionObjectInstance + throw e + } + } + else -> throw IllegalStateException("Message field only accept 'lengthDelimited' data.") + } + } + + fun readAny(input: CodedInputStream, field: Int, wire: Int, size: Int): Message<*, *> { + return when (wire) { + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + Any { + readFrom(input, size) + }.toMessage() + } + else -> throw IllegalStateException("Message field only accept 'lengthDelimited' data.") + } + } +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoSupport.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoSupport.kt new file mode 100644 index 00000000..fac47405 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoSupport.kt @@ -0,0 +1,113 @@ +package com.bybutter.sisyphus.protobuf + +import com.bybutter.sisyphus.protobuf.primitives.DescriptorProto +import com.bybutter.sisyphus.protobuf.primitives.FieldDescriptorProto +import com.google.protobuf.CodedInputStream +import io.grpc.Metadata +import io.grpc.MethodDescriptor +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.util.LinkedList + +abstract class ProtoSupport, TM : MutableMessage>(val fullName: String) : Metadata.BinaryMarshaller, MethodDescriptor.Marshaller { + abstract val descriptor: DescriptorProto + // val fields: List = _fields + + @InternalProtoApi + abstract fun newMutable(): TM + + open val fieldDescriptors: List by lazy { + descriptor.field + extensions.flatMap { it.extendedFields } + } + + private val fieldNameMap: Map by lazy { + fieldDescriptors.associateBy { it.name } + fieldDescriptors.associateBy { it.jsonName } + } + + private val fieldNumberMap: Map by lazy { + fieldDescriptors.associateBy { it.number } + } + + fun fieldInfo(name: String): FieldDescriptorProto? { + if (!name.contains('.')) { + return fieldNameMap[name] + } + + var target: ProtoSupport<*, *>? = this + var result: FieldDescriptorProto? = null + + val fieldPart = LinkedList(name.split('.')) + + while (fieldPart.isNotEmpty()) { + val field = fieldPart.poll() ?: break + + target ?: throw IllegalStateException("Nested property must be message") + result = target.fieldInfo(field) ?: return null + + if (result.type != FieldDescriptorProto.Type.MESSAGE) { + target = null + } else { + target = ProtoTypes.ensureSupportByProtoName(result.typeName) + if (target.descriptor.options?.mapEntry == true) { + val mapField = fieldPart.poll() ?: break + result = target.fieldInfo("value") ?: return null + if (result.type != FieldDescriptorProto.Type.MESSAGE) { + target = null + } else { + target = ProtoTypes.ensureSupportByProtoName(result.typeName) + } + } + } + } + + return result + } + + fun fieldInfo(number: Int): FieldDescriptorProto? { + return fieldNumberMap[number] + } + + override fun toBytes(value: T): ByteArray { + return value.toProto() + } + + override fun parseBytes(serialized: ByteArray): T { + return parse(serialized) + } + + override fun parse(stream: InputStream): T { + return parse(stream, Int.MAX_VALUE) + } + + override fun stream(value: T): InputStream { + return ByteArrayInputStream(value.toProto()) + } + + fun parse(input: InputStream, size: Int): T { + return parse(CodedInputStream.newInstance(input), size) + } + + fun parse(data: ByteArray, from: Int = 0, to: Int = data.size): T { + return parse(CodedInputStream.newInstance(data, from, to - from), to - from) + } + + @OptIn(InternalProtoApi::class) + fun parse(input: CodedInputStream, size: Int): T { + return newMutable().apply { readFrom(input, size) } as T + } + + @OptIn(InternalProtoApi::class) + fun parse(input: CodedInputStream, size: Int, block: TM.() -> Unit): T { + return newMutable().apply { + readFrom(input, size) + block() + } as T + } + + private val _extensions = mutableListOf>() + val extensions: List> = _extensions + + fun registerExtension(support: ExtensionSupport<*, *>) { + _extensions.add(support) + } +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoTypes.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoTypes.kt new file mode 100644 index 00000000..9494af85 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoTypes.kt @@ -0,0 +1,193 @@ +package com.bybutter.sisyphus.protobuf + +import com.bybutter.sisyphus.protobuf.primitives.DescriptorProto +import com.bybutter.sisyphus.protobuf.primitives.EnumDescriptorProto +import com.bybutter.sisyphus.protobuf.primitives.FieldDescriptorProto +import com.bybutter.sisyphus.protobuf.primitives.FileDescriptorProto +import com.bybutter.sisyphus.protobuf.primitives.MethodDescriptorProto +import com.bybutter.sisyphus.protobuf.primitives.ServiceDescriptorProto +import com.bybutter.sisyphus.spi.ServiceLoader +import com.google.common.collect.BiMap +import com.google.common.collect.HashBiMap +import kotlin.reflect.KClass +import kotlin.reflect.full.companionObjectInstance + +object ProtoTypes { + private val protoToClassMap: BiMap> = HashBiMap.create() + + // HashMap is faster than mutableMapOf(LinkedHashMap) + private val fileInfoMap: MutableMap = hashMapOf() + private val symbolMap: MutableMap = hashMapOf() + private val extensionMap: MutableMap> = hashMapOf() + + init { + ServiceLoader.load(ProtoFileMeta::class.java) + } + + fun registerFileMeta(file: ProtoFileMeta) { + registerFileSymbol(file.descriptor) + } + + fun registerProtoType(protoType: String, kotlinType: Class<*>) { + protoToClassMap[protoType] = kotlinType + } + + fun registerProtoType(protoType: String, kotlinType: KClass<*>) { + protoToClassMap[protoType] = kotlinType.java + } + + private fun registerFileSymbol(file: FileDescriptorProto) { + val packageName = file.`package` + fileInfoMap[file.name] = file + + for (descriptor in file.messageType) { + registerSymbol(file, packageName, descriptor) + } + + for (descriptor in file.enumType) { + registerSymbol(file, packageName, descriptor) + } + + for (descriptor in file.service) { + registerSymbol(file, packageName, descriptor) + } + + for (descriptor in file.extension) { + registerExtension(file, descriptor) + } + } + + private fun registerSymbol(file: FileDescriptorProto, prefix: String, descriptor: DescriptorProto) { + val prefix = "$prefix.${descriptor.name}" + symbolMap[prefix] = DescriptorInfo(file, descriptor) + + for (descriptor in descriptor.nestedType) { + registerSymbol(file, prefix, descriptor) + } + + for (descriptor in descriptor.enumType) { + registerSymbol(file, prefix, descriptor) + } + + for (descriptor in file.extension) { + registerExtension(file, descriptor) + } + } + + private fun registerSymbol(file: FileDescriptorProto, prefix: String, descriptor: EnumDescriptorProto) { + val prefix = "$prefix.${descriptor.name}" + symbolMap[prefix] = DescriptorInfo(file, descriptor) + } + + private fun registerSymbol(file: FileDescriptorProto, prefix: String, descriptor: ServiceDescriptorProto) { + val prefix = "$prefix.${descriptor.name}" + symbolMap[prefix] = DescriptorInfo(file, descriptor) + + for (descriptor in descriptor.method) { + registerSymbol(file, prefix, descriptor) + } + } + + private fun registerSymbol(file: FileDescriptorProto, prefix: String, descriptor: MethodDescriptorProto) { + val info = DescriptorInfo(file, descriptor) + symbolMap["$prefix/${descriptor.name}"] = info + symbolMap["$prefix.${descriptor.name}"] = info + } + + private fun registerExtension(file: FileDescriptorProto, descriptor: FieldDescriptorProto) { + val map = extensionMap.getOrPut(descriptor.extendee.trim('.')) { + mutableMapOf() + } + + map[descriptor.number] = DescriptorInfo(file, descriptor) + } + + fun getProtoNameByTypeUrl(url: String): String { + return url.substringAfter("/") + } + + fun getTypeUrlByProtoName(name: String, host: String = "type.bybutter.com"): String { + return "$host/$name" + } + + fun getClassByProtoName(name: String): Class<*>? { + return protoToClassMap[name.trim('.')] + } + + fun ensureClassByProtoName(name: String): Class<*> { + return getClassByProtoName(name) + ?: throw UnsupportedOperationException("Message '$name' not defined in current context.") + } + + fun getClassByTypeUrl(url: String): Class<*>? { + return getClassByProtoName(getProtoNameByTypeUrl(url)) + } + + fun ensureClassByTypeUrl(url: String): Class<*> { + return getClassByTypeUrl(url) + ?: throw UnsupportedOperationException("Message '$url' not defined in current context.") + } + + fun getSupportByProtoName(name: String): ProtoSupport<*, *>? { + return getClassByProtoName(name)?.kotlin?.companionObjectInstance as? ProtoSupport<*, *> + } + + fun ensureSupportByProtoName(name: String): ProtoSupport<*, *> { + return getSupportByProtoName(name) + ?: throw UnsupportedOperationException("Message '$name' not defined in current context.") + } + + fun getSupportByTypeUrl(url: String): ProtoSupport<*, *>? { + return getSupportByProtoName(getProtoNameByTypeUrl(url)) + } + + fun ensureSupportByTypeUrl(url: String): ProtoSupport<*, *> { + return getSupportByTypeUrl(url) + ?: throw UnsupportedOperationException("Message '$url' not defined in current context.") + } + + fun getProtoNameByClass(clazz: Class<*>): String? { + return protoToClassMap.inverse()[clazz] + } + + fun getDescriptorByProtoName(name: String): Any? { + return symbolMap[name.trim('.')]?.descriptor + } + + fun getDescriptorByTypeUrl(url: String): Any? { + return getDescriptorByProtoName(getProtoNameByTypeUrl(url)) + } + + fun getDescriptorByClass(clazz: Class<*>): Any? { + return getDescriptorByProtoName(getProtoNameByClass(clazz)!!) + } + + fun getFileDescriptorByName(name: String): FileDescriptorProto? { + return fileInfoMap[name] + } + + fun getFileContainingSymbol(name: String): FileDescriptorProto? { + return symbolMap[name.trim('.')]?.file + } + + fun getDescriptorBySymbol(name: String): Any? { + return symbolMap[name.trim('.')]?.descriptor + } + + fun getFileContainingExtension(name: String, number: Int): FileDescriptorProto? { + val extensions = extensionMap[name.trim('.')] ?: mutableMapOf() + return extensions[number]?.file + } + + fun getExtensionDescriptor(name: String, number: Int): FieldDescriptorProto? { + val extensions = extensionMap[name.trim('.')] ?: mutableMapOf() + return extensions[number]?.descriptor as? FieldDescriptorProto + } + + fun getTypeExtensions(name: String): Set { + val extensions = extensionMap[name.trim('.')] ?: mutableMapOf() + return extensions.keys + } +} + +private data class DescriptorInfo(val file: FileDescriptorProto, val descriptor: Any) diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoWriter.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoWriter.kt new file mode 100644 index 00000000..23f2c6a8 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/ProtoWriter.kt @@ -0,0 +1,236 @@ +package com.bybutter.sisyphus.protobuf + +import com.google.protobuf.CodedOutputStream +import com.google.protobuf.WireFormat + +@OptIn(ExperimentalUnsignedTypes::class) +object ProtoWriter { + fun writeEnum(output: CodedOutputStream, field: Int, value: ProtoEnum?) { + value ?: return + output.writeEnum(field, value.number) + } + + fun writeEnum(output: CodedOutputStream, field: Int, value: List) { + if (value.isEmpty()) return + output.writeTag(field, WireFormat.WIRETYPE_LENGTH_DELIMITED) + output.writeInt32NoTag(value.sumBy { CodedOutputStream.computeEnumSizeNoTag(it.number) }) + for (v in value) { + output.writeEnumNoTag(v.number) + } + } + + fun writeInt32(output: CodedOutputStream, field: Int, value: Int?) { + value ?: return + output.writeInt32(field, value) + } + + fun writeInt32(output: CodedOutputStream, field: Int, value: List) { + if (value.isEmpty()) return + output.writeTag(field, WireFormat.WIRETYPE_LENGTH_DELIMITED) + output.writeInt32NoTag(value.sumBy { CodedOutputStream.computeInt32SizeNoTag(it) }) + for (v in value) { + output.writeInt32NoTag(v) + } + } + + fun writeSInt32(output: CodedOutputStream, field: Int, value: Int?) { + value ?: return + output.writeSInt32(field, value) + } + + fun writeSInt32(output: CodedOutputStream, field: Int, value: List) { + if (value.isEmpty()) return + output.writeTag(field, WireFormat.WIRETYPE_LENGTH_DELIMITED) + output.writeSInt32NoTag(value.sumBy { CodedOutputStream.computeSInt32SizeNoTag(it) }) + for (v in value) { + output.writeSInt32NoTag(v) + } + } + + fun writeUInt32(output: CodedOutputStream, field: Int, value: UInt?) { + value ?: return + output.writeUInt32(field, value.toInt()) + } + + fun writeUInt32(output: CodedOutputStream, field: Int, value: List) { + if (value.isEmpty()) return + output.writeTag(field, WireFormat.WIRETYPE_LENGTH_DELIMITED) + output.writeUInt32NoTag(value.sumBy { CodedOutputStream.computeUInt32SizeNoTag(it.toInt()) }) + for (v in value) { + output.writeUInt32NoTag(v.toInt()) + } + } + + fun writeInt64(output: CodedOutputStream, field: Int, value: Long?) { + value ?: return + output.writeInt64(field, value) + } + + fun writeInt64(output: CodedOutputStream, field: Int, value: List) { + if (value.isEmpty()) return + output.writeTag(field, WireFormat.WIRETYPE_LENGTH_DELIMITED) + output.writeInt32NoTag(value.sumBy { CodedOutputStream.computeInt64SizeNoTag(it) }) + for (v in value) { + output.writeInt64NoTag(v) + } + } + + fun writeSInt64(output: CodedOutputStream, field: Int, value: Long?) { + value ?: return + output.writeSInt64(field, value) + } + + fun writeSInt64(output: CodedOutputStream, field: Int, value: List) { + if (value.isEmpty()) return + output.writeTag(field, WireFormat.WIRETYPE_LENGTH_DELIMITED) + output.writeInt32NoTag(value.sumBy { CodedOutputStream.computeSInt64SizeNoTag(it) }) + for (v in value) { + output.writeSInt64NoTag(v) + } + } + + fun writeUInt64(output: CodedOutputStream, field: Int, value: ULong?) { + value ?: return + output.writeUInt64(field, value.toLong()) + } + + fun writeUInt64(output: CodedOutputStream, field: Int, value: List) { + if (value.isEmpty()) return + output.writeTag(field, WireFormat.WIRETYPE_LENGTH_DELIMITED) + output.writeInt32NoTag(value.sumBy { CodedOutputStream.computeUInt64SizeNoTag(it.toLong()) }) + for (v in value) { + output.writeUInt64NoTag(v.toLong()) + } + } + + fun writeBool(output: CodedOutputStream, field: Int, value: Boolean?) { + value ?: return + output.writeBool(field, value) + } + + fun writeBool(output: CodedOutputStream, field: Int, value: List) { + if (value.isEmpty()) return + output.writeTag(field, WireFormat.WIRETYPE_LENGTH_DELIMITED) + output.writeInt32NoTag(value.sumBy { CodedOutputStream.computeBoolSizeNoTag(it) }) + for (v in value) { + output.writeBoolNoTag(v) + } + } + + fun writeFloat(output: CodedOutputStream, field: Int, value: Float?) { + value ?: return + output.writeFloat(field, value) + } + + fun writeFloat(output: CodedOutputStream, field: Int, value: List) { + if (value.isEmpty()) return + output.writeTag(field, WireFormat.WIRETYPE_LENGTH_DELIMITED) + output.writeInt32NoTag(value.sumBy { CodedOutputStream.computeFloatSizeNoTag(it) }) + for (v in value) { + output.writeFloatNoTag(v) + } + } + + fun writeSFixed32(output: CodedOutputStream, field: Int, value: Int?) { + value ?: return + output.writeSFixed32(field, value) + } + + fun writeSFixed32(output: CodedOutputStream, field: Int, value: List) { + if (value.isEmpty()) return + output.writeTag(field, WireFormat.WIRETYPE_LENGTH_DELIMITED) + output.writeInt32NoTag(value.sumBy { CodedOutputStream.computeSFixed32SizeNoTag(it) }) + for (v in value) { + output.writeSFixed32NoTag(v) + } + } + + fun writeFixed32(output: CodedOutputStream, field: Int, value: UInt?) { + value ?: return + output.writeFixed32(field, value.toInt()) + } + + fun writeFixed32(output: CodedOutputStream, field: Int, value: List) { + if (value.isEmpty()) return + output.writeTag(field, WireFormat.WIRETYPE_LENGTH_DELIMITED) + output.writeInt32NoTag(value.sumBy { CodedOutputStream.computeFixed32SizeNoTag(it.toInt()) }) + for (v in value) { + output.writeFixed32NoTag(v.toInt()) + } + } + + fun writeDouble(output: CodedOutputStream, field: Int, value: Double?) { + value ?: return + output.writeDouble(field, value) + } + + fun writeDouble(output: CodedOutputStream, field: Int, value: List) { + if (value.isEmpty()) return + output.writeTag(field, WireFormat.WIRETYPE_LENGTH_DELIMITED) + output.writeInt32NoTag(value.sumBy { CodedOutputStream.computeDoubleSizeNoTag(it) }) + for (v in value) { + output.writeDoubleNoTag(v) + } + } + + fun writeSFixed64(output: CodedOutputStream, field: Int, value: Long?) { + value ?: return + output.writeSFixed64(field, value) + } + + fun writeSFixed64(output: CodedOutputStream, field: Int, value: List) { + if (value.isEmpty()) return + output.writeTag(field, WireFormat.WIRETYPE_LENGTH_DELIMITED) + output.writeInt32NoTag(value.sumBy { CodedOutputStream.computeSFixed64SizeNoTag(it) }) + for (v in value) { + output.writeSFixed64NoTag(v) + } + } + + fun writeFixed64(output: CodedOutputStream, field: Int, value: ULong?) { + value ?: return + output.writeFixed64(field, value.toLong()) + } + + fun writeFixed64(output: CodedOutputStream, field: Int, value: List) { + if (value.isEmpty()) return + output.writeTag(field, WireFormat.WIRETYPE_LENGTH_DELIMITED) + output.writeInt32NoTag(value.sumBy { CodedOutputStream.computeFixed64SizeNoTag(it.toLong()) }) + for (v in value) { + output.writeFixed64NoTag(v.toLong()) + } + } + + fun writeString(output: CodedOutputStream, field: Int, value: String?) { + value ?: return + output.writeString(field, value) + } + + fun writeBytes(output: CodedOutputStream, field: Int, value: ByteArray?) { + value ?: return + output.writeByteArray(field, value) + } + + fun writeMessage(output: CodedOutputStream, field: Int, value: Message<*, *>?) { + value ?: return + output.writeTag(field, WireFormat.WIRETYPE_LENGTH_DELIMITED) + output.writeInt32NoTag(value.size()) + value.writeTo(output) + } + + fun writeAny(output: CodedOutputStream, field: Int, value: Message<*, *>?) { + value ?: return + output.writeTag(field, WireFormat.WIRETYPE_LENGTH_DELIMITED) + + val size = CodedOutputStream.computeStringSize(com.google.protobuf.Any.TYPE_URL_FIELD_NUMBER, value.typeUrl()) + + CodedOutputStream.computeTagSize(com.google.protobuf.Any.VALUE_FIELD_NUMBER) + + CodedOutputStream.computeInt32SizeNoTag(value.size()) + + value.size() + + output.writeInt32NoTag(size) + output.writeString(com.google.protobuf.Any.TYPE_URL_FIELD_NUMBER, value.typeUrl()) + output.writeTag(com.google.protobuf.Any.VALUE_FIELD_NUMBER, WireFormat.WIRETYPE_LENGTH_DELIMITED) + output.writeInt32NoTag(value.size()) + value.writeTo(output) + } +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/Size.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/Size.kt new file mode 100644 index 00000000..b26baeb9 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/Size.kt @@ -0,0 +1,192 @@ +package com.bybutter.sisyphus.protobuf + +import com.google.protobuf.CodedOutputStream + +@OptIn(ExperimentalUnsignedTypes::class) +object Size { + fun ofEnum(field: Int, value: ProtoEnum?): Int { + value ?: return 0 + return CodedOutputStream.computeEnumSize(field, value.number) + } + + fun ofEnum(field: Int, value: List): Int { + if (value.isEmpty()) return 0 + val size = value.sumBy { CodedOutputStream.computeEnumSizeNoTag(it.number) } + return CodedOutputStream.computeTagSize(field) + CodedOutputStream.computeInt32SizeNoTag(size) + size + } + + fun ofInt32(field: Int, value: Int?): Int { + value ?: return 0 + return CodedOutputStream.computeInt32Size(field, value) + } + + fun ofInt32(field: Int, value: List): Int { + if (value.isEmpty()) return 0 + val size = value.sumBy { CodedOutputStream.computeInt32SizeNoTag(it) } + return CodedOutputStream.computeTagSize(field) + CodedOutputStream.computeInt32SizeNoTag(size) + size + } + + fun ofSInt32(field: Int, value: Int?): Int { + value ?: return 0 + return CodedOutputStream.computeSInt32Size(field, value) + } + + fun ofSInt32(field: Int, value: List): Int { + if (value.isEmpty()) return 0 + val size = value.sumBy { CodedOutputStream.computeSInt32SizeNoTag(it) } + return CodedOutputStream.computeTagSize(field) + CodedOutputStream.computeInt32SizeNoTag(size) + size + } + + fun ofUInt32(field: Int, value: UInt?): Int { + value ?: return 0 + return CodedOutputStream.computeUInt32Size(field, value.toInt()) + } + + fun ofUInt32(field: Int, value: List): Int { + if (value.isEmpty()) return 0 + val size = value.sumBy { CodedOutputStream.computeUInt32SizeNoTag(it.toInt()) } + return CodedOutputStream.computeTagSize(field) + CodedOutputStream.computeInt32SizeNoTag(size) + size + } + + fun ofInt64(field: Int, value: Long?): Int { + value ?: return 0 + return CodedOutputStream.computeInt64Size(field, value) + } + + fun ofInt64(field: Int, value: List): Int { + if (value.isEmpty()) return 0 + val size = value.sumBy { CodedOutputStream.computeInt64SizeNoTag(it) } + return CodedOutputStream.computeTagSize(field) + CodedOutputStream.computeInt32SizeNoTag(size) + size + } + + fun ofSInt64(field: Int, value: Long?): Int { + value ?: return 0 + return CodedOutputStream.computeSInt64Size(field, value) + } + + fun ofSInt64(field: Int, value: List): Int { + if (value.isEmpty()) return 0 + val size = value.sumBy { CodedOutputStream.computeSInt64SizeNoTag(it) } + return CodedOutputStream.computeTagSize(field) + CodedOutputStream.computeInt32SizeNoTag(size) + size + } + + fun ofUInt64(field: Int, value: ULong?): Int { + value ?: return 0 + return CodedOutputStream.computeUInt64Size(field, value.toLong()) + } + + fun ofUInt64(field: Int, value: List): Int { + if (value.isEmpty()) return 0 + val size = value.sumBy { CodedOutputStream.computeUInt64SizeNoTag(it.toLong()) } + return CodedOutputStream.computeTagSize(field) + CodedOutputStream.computeInt32SizeNoTag(size) + size + } + + fun ofBool(field: Int, value: Boolean?): Int { + value ?: return 0 + return CodedOutputStream.computeBoolSize(field, value) + } + + fun ofBool(field: Int, value: List): Int { + if (value.isEmpty()) return 0 + val size = value.sumBy { CodedOutputStream.computeBoolSizeNoTag(it) } + return CodedOutputStream.computeTagSize(field) + CodedOutputStream.computeInt32SizeNoTag(size) + size + } + + fun ofFloat(field: Int, value: Float?): Int { + value ?: return 0 + return CodedOutputStream.computeFloatSize(field, value) + } + + fun ofFloat(field: Int, value: List): Int { + if (value.isEmpty()) return 0 + val size = value.sumBy { CodedOutputStream.computeFloatSizeNoTag(it) } + return CodedOutputStream.computeTagSize(field) + CodedOutputStream.computeInt32SizeNoTag(size) + size + } + + fun ofSFixed32(field: Int, value: Int?): Int { + value ?: return 0 + return CodedOutputStream.computeSFixed32Size(field, value) + } + + fun ofSFixed32(field: Int, value: List): Int { + if (value.isEmpty()) return 0 + val size = value.sumBy { CodedOutputStream.computeSFixed32SizeNoTag(it) } + return CodedOutputStream.computeTagSize(field) + CodedOutputStream.computeInt32SizeNoTag(size) + size + } + + fun ofFixed32(field: Int, value: UInt?): Int { + value ?: return 0 + return CodedOutputStream.computeFixed32Size(field, value.toInt()) + } + + fun ofFixed32(field: Int, value: List): Int { + if (value.isEmpty()) return 0 + val size = value.sumBy { CodedOutputStream.computeFixed32SizeNoTag(it.toInt()) } + return CodedOutputStream.computeTagSize(field) + CodedOutputStream.computeInt32SizeNoTag(size) + size + } + + fun ofDouble(field: Int, value: Double?): Int { + value ?: return 0 + return CodedOutputStream.computeDoubleSize(field, value) + } + + fun ofDouble(field: Int, value: List): Int { + if (value.isEmpty()) return 0 + val size = value.sumBy { CodedOutputStream.computeDoubleSizeNoTag(it) } + return CodedOutputStream.computeTagSize(field) + CodedOutputStream.computeInt32SizeNoTag(size) + size + } + + fun ofSFixed64(field: Int, value: Long?): Int { + value ?: return 0 + return CodedOutputStream.computeSFixed64Size(field, value) + } + + fun ofSFixed64(field: Int, value: List): Int { + if (value.isEmpty()) return 0 + val size = value.sumBy { CodedOutputStream.computeSFixed64SizeNoTag(it) } + return CodedOutputStream.computeTagSize(field) + CodedOutputStream.computeInt32SizeNoTag(size) + size + } + + fun ofFixed64(field: Int, value: ULong?): Int { + value ?: return 0 + return CodedOutputStream.computeFixed64Size(field, value.toLong()) + } + + fun ofFixed64(field: Int, value: List): Int { + if (value.isEmpty()) return 0 + val size = value.sumBy { CodedOutputStream.computeFixed64SizeNoTag(it.toLong()) } + return CodedOutputStream.computeTagSize(field) + CodedOutputStream.computeInt32SizeNoTag(size) + size + } + + fun ofString(field: Int, value: String?): Int { + value ?: return 0 + return CodedOutputStream.computeStringSize(field, value) + } + + fun ofBytes(field: Int, value: ByteArray?): Int { + value ?: return 0 + return CodedOutputStream.computeByteArraySize(field, value) + } + + fun ofMessage(field: Int, value: Message<*, *>?): Int { + value ?: return 0 + val size = value.size() + return CodedOutputStream.computeTagSize(field) + CodedOutputStream.computeInt32SizeNoTag(size) + size + } + + fun ofMessage(field: Int, size: Int?): Int { + size ?: return 0 + return CodedOutputStream.computeTagSize(field) + CodedOutputStream.computeInt32SizeNoTag(size) + size + } + + fun ofAny(field: Int, value: Message<*, *>?): Int { + value ?: return 0 + var size = value.size() + size += CodedOutputStream.computeInt32SizeNoTag(size) + size += CodedOutputStream.computeTagSize(com.google.protobuf.Any.VALUE_FIELD_NUMBER) + size += CodedOutputStream.computeStringSize(com.google.protobuf.Any.TYPE_URL_FIELD_NUMBER, value.typeUrl()) + size += CodedOutputStream.computeInt32SizeNoTag(size) + size += CodedOutputStream.computeTagSize(field) + return size + } +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/UnknownField.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/UnknownField.kt new file mode 100644 index 00000000..df40a91c --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/UnknownField.kt @@ -0,0 +1,161 @@ +package com.bybutter.sisyphus.protobuf + +import com.bybutter.sisyphus.collection.contentEquals +import com.bybutter.sisyphus.collection.takeWhen +import com.google.protobuf.CodedInputStream +import com.google.protobuf.CodedOutputStream +import com.google.protobuf.WireFormat +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +data class UnknownField( + var number: Int, + var wireType: Int, + var data: ByteArray +) { + val size: Int + get() { + return CodedOutputStream.computeTagSize(this.number) + when (this.wireType) { + WireFormat.WIRETYPE_VARINT -> this.data.size + WireFormat.WIRETYPE_FIXED32 -> this.data.size + WireFormat.WIRETYPE_FIXED64 -> this.data.size + WireFormat.WIRETYPE_LENGTH_DELIMITED -> CodedOutputStream.computeInt32SizeNoTag(this.data.size) + this.data.size + else -> throw UnsupportedOperationException("Unsupported wire type '${this.wireType}'.") + } + } + + fun writeTo(output: CodedOutputStream) { + output.writeTag(this.number, this.wireType) + when (this.wireType) { + WireFormat.WIRETYPE_VARINT, + WireFormat.WIRETYPE_FIXED32, + WireFormat.WIRETYPE_FIXED64 -> { + output.writeRawBytes(this.data) + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + output.writeByteArrayNoTag(this.data) + } + else -> throw UnsupportedOperationException("Unsupported wire type '${this.wireType}'.") + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UnknownField + + if (number != other.number) return false + if (wireType != other.wireType) return false + if (!data.contentEquals(other.data)) return false + + return true + } + + override fun hashCode(): Int { + var result = number + result = 31 * result + wireType + result = 31 * result + data.contentHashCode() + return result + } +} + +class UnknownFields { + val fields: List get() = _fields + private val _fields: MutableList = mutableListOf() + + val size: Int + get() { + return fields.sumBy { it.size } + } + + fun writeTo(output: CodedOutputStream) { + for (field in fields) { + field.writeTo(output) + } + } + + fun readFrom(input: CodedInputStream, number: Int, wireType: Int) { + when (wireType) { + WireFormat.WIRETYPE_VARINT -> { + ByteArrayOutputStream().use { + do { + val byte = input.readRawByte().toInt() + it.write(byte) + } while (byte and 0x80 > 0) + _fields.add(UnknownField(number, wireType, it.toByteArray())) + } + } + WireFormat.WIRETYPE_FIXED32 -> { + _fields.add(UnknownField(number, wireType, input.readRawBytes(4))) + } + WireFormat.WIRETYPE_FIXED64 -> { + _fields.add(UnknownField(number, wireType, input.readRawBytes(8))) + } + WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + _fields.add(UnknownField(number, wireType, input.readByteArray())) + } + else -> throw UnsupportedOperationException("Unsupported wire type '$wireType'.") + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UnknownFields + + if (!fields.contentEquals(other.fields)) return false + return true + } + + override fun hashCode(): Int { + var result = this.javaClass.hashCode() + for (field in fields) { + result = result * 31 + field.hashCode() + } + return result + } + + fun clear(): List { + return _fields.toList().apply { + _fields.clear() + } + } + + operator fun plus(other: UnknownFields): UnknownFields { + return UnknownFields().apply { + this += this@UnknownFields + this += other + } + } + + operator fun plusAssign(other: UnknownFields) { + this._fields.addAll(other.fields) + } + + @UseExperimental(InternalProtoApi::class) + fun exportExtension(support: ExtensionSupport<*, *>): Message<*, *> { + val extendedFields = support.extendedFields.map { it.number }.toSet() + val extended = support.extendedFields + + val fieldsData = _fields.takeWhen { + it.number in extendedFields + } + + val buffer = ByteBuffer.allocate(fieldsData.sumBy { it.size }) + val output = CodedOutputStream.newInstance(buffer) + + for (field in fieldsData) { + field.writeTo(output) + } + output.flush() + + buffer.rewind() + val input = CodedInputStream.newInstance(buffer) + + return support.newMutable().apply { + readFrom(input, Int.MAX_VALUE) + } + } +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/WellKnownTypes.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/WellKnownTypes.kt new file mode 100644 index 00000000..9f290185 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/WellKnownTypes.kt @@ -0,0 +1,5 @@ +package com.bybutter.sisyphus.protobuf + +object WellKnownTypes { + const val ANY_TYPENAME = ".google.protobuf.Any" +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/jackson/ProtoDeserializer.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/jackson/ProtoDeserializer.kt new file mode 100644 index 00000000..59faeb0d --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/jackson/ProtoDeserializer.kt @@ -0,0 +1,414 @@ +package com.bybutter.sisyphus.protobuf.jackson + +import com.bybutter.sisyphus.jackson.javaType +import com.bybutter.sisyphus.protobuf.CustomProtoType +import com.bybutter.sisyphus.protobuf.CustomProtoTypeSupport +import com.bybutter.sisyphus.protobuf.InternalProtoApi +import com.bybutter.sisyphus.protobuf.Message +import com.bybutter.sisyphus.protobuf.MutableMessage +import com.bybutter.sisyphus.protobuf.ProtoEnum +import com.bybutter.sisyphus.protobuf.ProtoStringEnum +import com.bybutter.sisyphus.protobuf.ProtoSupport +import com.bybutter.sisyphus.protobuf.ProtoTypes +import com.bybutter.sisyphus.protobuf.primitives.BoolValue +import com.bybutter.sisyphus.protobuf.primitives.BytesValue +import com.bybutter.sisyphus.protobuf.primitives.DoubleValue +import com.bybutter.sisyphus.protobuf.primitives.Duration +import com.bybutter.sisyphus.protobuf.primitives.FieldMask +import com.bybutter.sisyphus.protobuf.primitives.FloatValue +import com.bybutter.sisyphus.protobuf.primitives.Int32Value +import com.bybutter.sisyphus.protobuf.primitives.Int64Value +import com.bybutter.sisyphus.protobuf.primitives.ListValue +import com.bybutter.sisyphus.protobuf.primitives.NullValue +import com.bybutter.sisyphus.protobuf.primitives.StringValue +import com.bybutter.sisyphus.protobuf.primitives.Struct +import com.bybutter.sisyphus.protobuf.primitives.Timestamp +import com.bybutter.sisyphus.protobuf.primitives.UInt32Value +import com.bybutter.sisyphus.protobuf.primitives.UInt64Value +import com.bybutter.sisyphus.protobuf.primitives.Value +import com.bybutter.sisyphus.protobuf.primitives.invoke +import com.bybutter.sisyphus.security.base64UrlSafeDecode +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JavaType +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import kotlin.Boolean +import kotlin.ByteArray +import kotlin.Double +import kotlin.Enum +import kotlin.ExperimentalUnsignedTypes +import kotlin.Float +import kotlin.IllegalStateException +import kotlin.Int +import kotlin.Long +import kotlin.OptIn +import kotlin.String +import kotlin.UInt +import kotlin.ULong +import kotlin.UnsupportedOperationException +import kotlin.apply +import kotlin.let +import kotlin.reflect.full.companionObjectInstance +import kotlin.reflect.full.isSuperclassOf +import kotlin.reflect.jvm.javaType +import kotlin.toUInt +import kotlin.toULong + +@OptIn(ExperimentalUnsignedTypes::class) +open class ProtoDeserializer> : StdDeserializer { + + constructor(type: Class) : super(type) + + constructor(type: JavaType) : super(type) + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): T? { + return readAny(handledType().javaType, p, ctxt) as? T + } + + protected fun readAny(type: JavaType, p: JsonParser, ctxt: DeserializationContext): kotlin.Any? { + if (p.currentToken == JsonToken.VALUE_NULL) { + return when (type.rawClass) { + NullValue::class.java -> NullValue.NULL_VALUE + Value.Kind.NullValue::class.java -> Value.Kind.NullValue(NullValue.NULL_VALUE) + else -> null + } + } + + return when (type.rawClass) { + Int::class.javaObjectType, + Int::class.java -> p.intValue + UInt::class.javaObjectType, + UInt::class.java -> p.longValue.toUInt() + Long::class.javaObjectType, + Long::class.java -> p.longValue + ULong::class.javaObjectType, + ULong::class.java -> p.bigIntegerValue.toLong().toULong() + Double::class.javaObjectType, + Double::class.java -> p.doubleValue + Float::class.javaObjectType, + Float::class.java -> p.floatValue + Int32Value::class.java -> Int32Value { + value = p.intValue + } + Int64Value::class.java -> Int64Value { + value = p.longValue + } + UInt32Value::class.java -> UInt32Value { + value = p.longValue.toUInt() + } + UInt64Value::class.java -> UInt64Value { + value = p.bigIntegerValue.toLong().toULong() + } + Value.Kind.NumberValue::class.java -> Value.Kind.NumberValue(p.doubleValue) + ByteArray::class.java -> p.text.base64UrlSafeDecode() + BytesValue::class.java -> BytesValue { + value = p.text.base64UrlSafeDecode() + } + Boolean::class.javaObjectType, + Boolean::class.java -> p.booleanValue + BoolValue::class.java -> BoolValue { + value = p.booleanValue + } + Value.Kind.BoolValue::class.java -> Value.Kind.BoolValue(p.booleanValue) + String::class.java -> p.text + StringValue::class.java -> StringValue { + value = p.text + } + Value.Kind.StringValue::class.java -> Value.Kind.StringValue(p.text) + FieldMask::class.java -> readFieldMask(type, p, ctxt) + Message::class.java, + Any::class.java -> readAnyProto(type, p, ctxt) + Timestamp::class.java -> readTimestamp(type, p, ctxt) + Duration::class.java -> readDuration(type, p, ctxt) + ListValue::class.java -> readList(p, ctxt) + Value::class.java -> readValue(p, ctxt) + Struct::class.java -> readStruct(p, ctxt) + else -> { + when { + Enum::class.java.isAssignableFrom(type.rawClass) -> readEnum(type, p, ctxt) + List::class.java.isAssignableFrom(type.rawClass) -> readList(type, p, ctxt) + Map::class.java.isAssignableFrom(type.rawClass) -> readMap(type, p, ctxt) + Message::class.java.isAssignableFrom(type.rawClass) -> readRawProto(type, p, ctxt) + CustomProtoType::class.java.isAssignableFrom(type.rawClass) -> readCustom(type, p, ctxt) + else -> throw UnsupportedOperationException("Type '${type.toCanonical()}' not supported in proto.") + } + } + } + } + + private fun readProtoFields(value: MutableMessage<*, *>, p: JsonParser, ctxt: DeserializationContext) { + var current = p.currentToken + while (current != null) { + if (current == JsonToken.END_OBJECT) { + break + } + + if (current != JsonToken.FIELD_NAME) { + throw IllegalStateException("Read illegal json token '$current', but should be '${JsonToken.FIELD_NAME}'.") + } + + val propertyName = p.currentName + current = p.nextToken() + val property = value.getProperty(propertyName) + if (property != null) { + value[propertyName] = readAny(property.returnType.javaType.javaType, p, ctxt) + } + current = p.nextToken() + } + } + + @OptIn(InternalProtoApi::class) + private fun readRawProto(type: JavaType, p: JsonParser, ctxt: DeserializationContext): Message<*, *>? { + if (p.currentToken != JsonToken.START_OBJECT) { + throw IllegalStateException("Read illegal json token '${p.currentToken}', but should be '${JsonToken.START_OBJECT}'.") + } + + val support = type.rawClass.kotlin.companionObjectInstance as ProtoSupport<*, *> + return support.newMutable().apply { + p.nextToken() + readProtoFields(this, p, ctxt) + } + } + + private fun readCustom(type: JavaType, p: JsonParser, ctxt: DeserializationContext): CustomProtoType<*> { + val support = type.rawClass.kotlin.companionObjectInstance as CustomProtoTypeSupport, Any?> + val raw = readAny(support.rawType.javaType, p, ctxt) + return support.wrapRaw(raw) + } + + @OptIn(InternalProtoApi::class) + private fun readAnyProto(type: JavaType, p: JsonParser, ctxt: DeserializationContext): Message<*, *>? { + if (p.currentToken != JsonToken.START_OBJECT) { + throw IllegalStateException("Read illegal json token '${p.currentToken}', but should be '${JsonToken.START_OBJECT}'.") + } + + if (p.nextFieldName() != "@type") { + throw IllegalStateException("Any proto need begin with '@type' field, but be '${p.nextFieldName()}'.") + } + + val typeUrl = p.nextTextValue() + ?: throw IllegalStateException("Any proto need '@type' field with String value.") + val protoType = ProtoTypes.getClassByProtoName(typeUrl.substringAfter("/")) + ?: throw IllegalStateException("Proto type '$typeUrl' not registered in current context.") + + return when (protoType) { + is Timestamp, + is Duration, + is Value, + is DoubleValue, + is FloatValue, + is Int32Value, + is Int64Value, + is UInt32Value, + is UInt64Value, + is BoolValue, + is StringValue, + is BytesValue, + is ListValue, + is FieldMask, + is Struct -> { + var result: kotlin.Any? = null + var current = p.nextToken() + while (current != null) { + if (current == JsonToken.END_OBJECT) { + break + } + + if (current != JsonToken.FIELD_NAME) { + throw IllegalStateException("Read illegal json token '$current', but should be '${JsonToken.FIELD_NAME}'.") + } + + val propertyName = p.currentName + current = p.nextToken() + + if (propertyName == "value") { + result = readAny(protoType.javaType, p, ctxt) + } + p.skipChildren() + current = p.nextToken() + } + return result as Message<*, *> + } + else -> { + val support = protoType.kotlin.companionObjectInstance as ProtoSupport<*, *> + support.newMutable().apply { + p.nextToken() + readProtoFields(this, p, ctxt) + } + } + } + } + + private fun readEnum(type: JavaType, p: JsonParser, ctxt: DeserializationContext): kotlin.Any? { + return when (p.currentToken) { + JsonToken.VALUE_STRING -> { + if (ProtoStringEnum::class.isSuperclassOf(type.rawClass.kotlin)) { + ProtoStringEnum.fromValue(p.text, type.rawClass as Class) + ?: ProtoEnum(p.text, type.rawClass as Class) + } else { + ProtoEnum(p.text, type.rawClass as Class) + } + } + JsonToken.VALUE_NUMBER_INT -> { + ProtoEnum(p.intValue, type.rawClass as Class) + } + else -> throw IllegalStateException("Enum value muse be number or string in json, but '${p.currentToken}' read.") + } + } + + private fun readTimestamp(type: JavaType, p: JsonParser, ctxt: DeserializationContext): Timestamp? { + if (p.currentToken != JsonToken.VALUE_STRING) { + throw IllegalStateException("Read illegal json token '${p.currentToken}', but should be '${JsonToken.FIELD_NAME}'.") + } + + return Timestamp(p.text) + } + + private fun readDuration(type: JavaType, p: JsonParser, ctxt: DeserializationContext): Duration? { + if (p.currentToken != JsonToken.VALUE_STRING) { + throw IllegalStateException("Read illegal json token '${p.currentToken}', but should be '${JsonToken.VALUE_STRING}'.") + } + return Duration(p.text) + } + + private fun readFieldMask(type: JavaType, p: JsonParser, ctxt: DeserializationContext): FieldMask? { + if (p.currentToken != JsonToken.VALUE_STRING) { + throw IllegalStateException("Read illegal json token '${p.currentToken}', but should be '${JsonToken.VALUE_STRING}'.") + } + + return FieldMask { + paths += p.text.split(',') + } + } + + private fun readValue(p: JsonParser, ctxt: DeserializationContext): Value? { + return Value { + kind = when (p.currentToken) { + JsonToken.VALUE_STRING -> { + (p.text.toDoubleOrNull()?.let { + Value.Kind.NumberValue(it) + } ?: Value.Kind.StringValue(p.text)) + } + JsonToken.VALUE_NUMBER_INT, + JsonToken.VALUE_NUMBER_FLOAT -> Value.Kind.NumberValue(p.doubleValue) + JsonToken.VALUE_TRUE, + JsonToken.VALUE_FALSE -> Value.Kind.BoolValue(p.booleanValue) + JsonToken.VALUE_NULL -> Value.Kind.NullValue(NullValue.NULL_VALUE) + JsonToken.START_OBJECT -> Value.Kind.StructValue(readStruct(p, ctxt)) + JsonToken.START_ARRAY -> Value.Kind.ListValue(readList(p, ctxt)) + else -> throw IllegalStateException("Read illegal json token '${p.currentToken}'.") + } + } + } + + private fun readStruct(p: JsonParser, ctxt: DeserializationContext): Struct { + if (p.currentToken != JsonToken.START_OBJECT) { + throw IllegalStateException("Read illegal json token '${p.currentToken}', but should be '${JsonToken.START_OBJECT}'.") + } + return Struct { + val result = mutableMapOf() + var current = p.nextToken() + while (current != null) { + if (current == JsonToken.END_OBJECT) { + break + } + + if (current != JsonToken.FIELD_NAME) { + throw IllegalStateException("Read illegal json token '${p.currentToken}', but should be '${JsonToken.FIELD_NAME}'.") + } + + val propertyName = p.currentName + current = p.nextToken() + readValue(p, ctxt)?.let { + result[propertyName] = it + } + current = p.nextToken() + } + fields += result + } + } + + private fun readList(p: JsonParser, ctxt: DeserializationContext): ListValue { + if (p.currentToken != JsonToken.START_ARRAY) { + throw IllegalStateException("Read illegal json token '${p.currentToken}', but should be '${JsonToken.START_ARRAY}'.") + } + + return ListValue { + val result = mutableListOf() + var current = p.nextToken() + while (current != null) { + if (current == JsonToken.END_ARRAY) { + break + } + + readValue(p, ctxt)?.let { + result.add(it) + } + current = p.nextToken() + } + values += result + } + } + + private fun readList(type: JavaType, p: JsonParser, ctxt: DeserializationContext): List<*>? { + if (p.currentToken != JsonToken.START_ARRAY) { + throw IllegalStateException("Read illegal json token '${p.currentToken}', but should be '${JsonToken.START_ARRAY}'.") + } + + val targetType = type.findTypeParameters(List::class.java).first() + + val result = mutableListOf() + var current = p.nextToken() + while (current != null) { + if (current == JsonToken.END_ARRAY) { + break + } + + result += readAny(targetType, p, ctxt) + current = p.nextToken() + } + return result + } + + private fun readMap(type: JavaType, p: JsonParser, ctxt: DeserializationContext): Map<*, *>? { + if (p.currentToken != JsonToken.START_OBJECT) { + throw IllegalStateException("Read illegal json token '${p.currentToken}', but should be '${JsonToken.START_OBJECT}'.") + } + + val targetType = type.findTypeParameters(Map::class.java) + + val result = mutableMapOf() + var current = p.nextToken() + while (current != null) { + if (current == JsonToken.END_OBJECT) { + break + } + + if (current != JsonToken.FIELD_NAME) { + throw IllegalStateException("Read illegal json token '$current', but should be '${JsonToken.FIELD_NAME}'.") + } + + val propertyName = when (targetType[0].rawClass) { + Int::class.javaObjectType, + Int::class.java -> p.currentName.toInt() + UInt::class.javaObjectType, + UInt::class.java -> p.currentName.toUInt() + Long::class.javaObjectType, + Long::class.java -> p.currentName.toLong() + ULong::class.javaObjectType, + ULong::class.java -> p.currentName.toULong() + Boolean::class.javaObjectType, + Boolean::class.java -> p.currentName.toBoolean() + String::class.java -> p.currentName + else -> throw IllegalStateException("Type of map key must be string or number.") + } + current = p.nextToken() + readAny(targetType[1], p, ctxt)?.let { + result[propertyName] = it + } + current = p.nextToken() + } + return result + } +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/jackson/ProtoModule.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/jackson/ProtoModule.kt new file mode 100644 index 00000000..77d7885d --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/jackson/ProtoModule.kt @@ -0,0 +1,41 @@ +package com.bybutter.sisyphus.protobuf.jackson + +import com.bybutter.sisyphus.protobuf.Message +import com.fasterxml.jackson.databind.BeanDescription +import com.fasterxml.jackson.databind.DeserializationConfig +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonSerializer +import com.fasterxml.jackson.databind.SerializationConfig +import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.databind.ser.BeanSerializerModifier + +class ProtoModule : SimpleModule() { + override fun setupModule(context: SetupContext) { + context.addBeanSerializerModifier(object : BeanSerializerModifier() { + override fun modifySerializer( + config: SerializationConfig?, + beanDesc: BeanDescription, + serializer: JsonSerializer<*> + ): JsonSerializer<*> { + if (Message::class.java.isAssignableFrom(beanDesc.beanClass)) { + return ProtoSerializer>(beanDesc.type) + } + return super.modifySerializer(config, beanDesc, serializer) + } + }) + + context.addBeanDeserializerModifier(object : BeanDeserializerModifier() { + override fun modifyDeserializer( + config: DeserializationConfig, + beanDesc: BeanDescription, + deserializer: JsonDeserializer<*> + ): JsonDeserializer<*> { + if (Message::class.java.isAssignableFrom(beanDesc.beanClass)) { + return ProtoDeserializer>(beanDesc.type) + } + return super.modifyDeserializer(config, beanDesc, deserializer) + } + }) + } +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/jackson/ProtoSerializer.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/jackson/ProtoSerializer.kt new file mode 100644 index 00000000..ebb78c53 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/jackson/ProtoSerializer.kt @@ -0,0 +1,325 @@ +package com.bybutter.sisyphus.protobuf.jackson + +import com.bybutter.sisyphus.protobuf.CustomProtoType +import com.bybutter.sisyphus.protobuf.Message +import com.bybutter.sisyphus.protobuf.ProtoEnum +import com.bybutter.sisyphus.protobuf.WellKnownTypes +import com.bybutter.sisyphus.protobuf.primitives.BoolValue +import com.bybutter.sisyphus.protobuf.primitives.BytesValue +import com.bybutter.sisyphus.protobuf.primitives.DoubleValue +import com.bybutter.sisyphus.protobuf.primitives.Duration +import com.bybutter.sisyphus.protobuf.primitives.FieldDescriptorProto +import com.bybutter.sisyphus.protobuf.primitives.FieldMask +import com.bybutter.sisyphus.protobuf.primitives.FloatValue +import com.bybutter.sisyphus.protobuf.primitives.Int32Value +import com.bybutter.sisyphus.protobuf.primitives.Int64Value +import com.bybutter.sisyphus.protobuf.primitives.ListValue +import com.bybutter.sisyphus.protobuf.primitives.NullValue +import com.bybutter.sisyphus.protobuf.primitives.StringValue +import com.bybutter.sisyphus.protobuf.primitives.Struct +import com.bybutter.sisyphus.protobuf.primitives.Timestamp +import com.bybutter.sisyphus.protobuf.primitives.UInt32Value +import com.bybutter.sisyphus.protobuf.primitives.UInt64Value +import com.bybutter.sisyphus.protobuf.primitives.Value +import com.bybutter.sisyphus.protobuf.primitives.string +import com.bybutter.sisyphus.security.base64UrlSafeWithPadding +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.JavaType +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator +import java.lang.reflect.Type +import kotlin.math.round + +@OptIn(ExperimentalUnsignedTypes::class) +open class ProtoSerializer> : StdSerializer { + companion object { + private val oneOfCache = mutableMapOf() + } + + constructor(type: Class) : super(type) + + constructor(type: JavaType) : super(type) + + override fun serialize(value: T, gen: JsonGenerator, provider: SerializerProvider) { + writeAny(value as Any, gen, provider) + } + + private fun getSerializedPropertyName(field: FieldDescriptorProto, gen: JsonGenerator, provider: SerializerProvider): String { + return when (gen) { + is YAMLGenerator -> field.name + else -> field.jsonName + } + } + + protected fun serializeProperties(value: Message<*, *>, gen: JsonGenerator, provider: SerializerProvider) { + for ((field, fieldValue) in value) { + if (!value.has(field.number)) { + continue + } + + gen.writeFieldName(getSerializedPropertyName(field, gen, provider)) + if (field.typeName == WellKnownTypes.ANY_TYPENAME) { + if (fieldValue is List<*>) { + writeAnyList(fieldValue as List>, gen, provider) + } else { + writeAny(fieldValue as Message<*, *>, gen, provider) + } + } else { + writeAny(fieldValue!!, gen, provider) + } + } + } + + protected fun writeAny(value: Any, gen: JsonGenerator, provider: SerializerProvider) { + when (value) { + is NullValue -> writeNull(value, gen, provider) + is String -> gen.writeString(value) + is ProtoEnum -> writeEnum(value, gen, provider) + is Number -> writeNumber(value, gen, provider) + is ByteArray -> writeBytes(value, gen, provider) + is Boolean -> writeBoolean(value, gen, provider) + is Timestamp -> writeTimestamp(value, gen, provider) + is Duration -> writeDuration(value, gen, provider) + is Struct -> writeStruct(value, gen, provider) + is Value -> writeValue(value, gen, provider) + is DoubleValue -> writeDouble(value, gen, provider) + is FloatValue -> writeFloat(value, gen, provider) + is Int64Value -> writeInt64(value, gen, provider) + is UInt64Value -> writeUInt64(value, gen, provider) + is Int32Value -> writeInt32(value, gen, provider) + is UInt32Value -> writeUInt32(value, gen, provider) + is BoolValue -> writeBoolean(value, gen, provider) + is StringValue -> writeString(value, gen, provider) + is BytesValue -> writeBytes(value, gen, provider) + is ListValue -> writeList(value, gen, provider) + is FieldMask -> writeFieldMask(value, gen, provider) + is com.bybutter.sisyphus.protobuf.primitives.Any -> writeAny(value, gen, provider) + is Message<*, *> -> writeRawProto(value, gen, provider) + is CustomProtoType<*> -> writeCustom(value, gen, provider) + is List<*> -> writeList(value, gen, provider) + is Map<*, *> -> writeMap(value, gen, provider) + else -> { + throw UnsupportedOperationException("Unsupported type '${value.javaClass}($value)' in proto.") + } + } + } + + protected fun writeRawProto(value: Message<*, *>, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeStartObject() + serializeProperties(value, gen, provider) + gen.writeEndObject() + } + + protected fun writeCustom(value: CustomProtoType<*>, gen: JsonGenerator, provider: SerializerProvider) { + writeAny(value.raw() ?: NullValue.NULL_VALUE, gen, provider) + } + + protected fun writeAnyList(value: List>, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeStartArray() + for (message in value) { + writeAny(message, gen, provider) + } + gen.writeEndArray() + } + + protected fun writeAny(value: Message<*, *>, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeStartObject() + gen.writeStringField("@type", value.typeUrl()) + when (value) { + is Timestamp -> { + gen.writeFieldName("value") + writeTimestamp(value, gen, provider) + } + is Duration -> { + gen.writeFieldName("value") + writeDuration(value, gen, provider) + } + is Value -> { + gen.writeFieldName("value") + writeValue(value, gen, provider) + } + is DoubleValue -> { + gen.writeFieldName("value") + writeDouble(value, gen, provider) + } + is FloatValue -> { + gen.writeFieldName("value") + writeFloat(value, gen, provider) + } + is Int32Value -> { + gen.writeFieldName("value") + writeInt32(value, gen, provider) + } + is Int64Value -> { + gen.writeFieldName("value") + writeInt64(value, gen, provider) + } + is UInt32Value -> { + gen.writeFieldName("value") + writeUInt32(value, gen, provider) + } + is UInt64Value -> { + gen.writeFieldName("value") + writeUInt64(value, gen, provider) + } + is BoolValue -> { + gen.writeFieldName("value") + writeBoolean(value, gen, provider) + } + is StringValue -> { + gen.writeFieldName("value") + writeString(value, gen, provider) + } + is BytesValue -> { + gen.writeFieldName("value") + writeBytes(value, gen, provider) + } + is ListValue -> { + gen.writeFieldName("value") + writeList(value, gen, provider) + } + is FieldMask -> { + gen.writeFieldName("value") + writeFieldMask(value, gen, provider) + } + is Struct -> { + gen.writeFieldName("value") + writeStruct(value, gen, provider) + } + else -> serializeProperties(value, gen, provider) + } + gen.writeEndObject() + } + + protected fun writeEnum(value: ProtoEnum, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeString(value.proto) + } + + protected fun writeNumber(value: Number, gen: JsonGenerator, provider: SerializerProvider) { + if ((value as? Float)?.isInfinite() == true || + (value as? Float)?.isNaN() == true || + (value as? Double)?.isInfinite() == true || + (value as? Double)?.isNaN() == true) { + gen.writeString(value.toString()) + } + + if (round(value.toDouble()) == value.toDouble()) { + gen.writeNumber(value.toLong()) + } else { + gen.writeNumber(value.toString()) + } + } + + protected fun writeBytes(value: ByteArray, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeString(value.base64UrlSafeWithPadding()) + } + + protected fun writeBoolean(value: Boolean, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeBoolean(value) + } + + protected fun writeTimestamp(value: Timestamp, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeString(value.string()) + } + + protected fun writeDuration(value: Duration, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeString(value.string()) + } + + protected fun writeStruct(value: Struct, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeStartObject() + for (property in value.fields.keys) { + val propertyValue = value.fields[property] ?: continue + gen.writeFieldName(property) + writeValue(propertyValue, gen, provider) + } + gen.writeEndObject() + } + + protected fun writeValue(value: Value, gen: JsonGenerator, provider: SerializerProvider) { + when (val kind = value.kind) { + is Value.Kind.BoolValue -> gen.writeBoolean(kind.value) + is Value.Kind.NumberValue -> writeNumber(kind.value, gen, provider) + is Value.Kind.StringValue -> gen.writeString(kind.value) + is Value.Kind.StructValue -> writeStruct(kind.value, gen, provider) + is Value.Kind.ListValue -> { + writeList(kind.value, gen, provider) + } + is Value.Kind.NullValue -> gen.writeNull() + else -> gen.writeNull() + } + } + + protected fun writeDouble(value: DoubleValue, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeNumber(value.value) + } + + protected fun writeFloat(value: FloatValue, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeNumber(value.value) + } + + protected fun writeInt64(value: Int64Value, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeNumber(value.value) + } + + protected fun writeUInt64(value: UInt64Value, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeNumber(value.value.toString()) + } + + protected fun writeInt32(value: Int32Value, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeNumber(value.value) + } + + protected fun writeUInt32(value: UInt32Value, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeNumber(value.value.toString()) + } + + protected fun writeBoolean(value: BoolValue, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeBoolean(value.value) + } + + protected fun writeString(value: StringValue, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeString(value.value) + } + + protected fun writeBytes(value: BytesValue, gen: JsonGenerator, provider: SerializerProvider) { + writeBytes(value.value, gen, provider) + } + + protected fun writeNull(value: NullValue, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeNull() + } + + protected fun writeList(value: ListValue, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeStartArray() + for (item in value.values) { + writeValue(item, gen, provider) + } + gen.writeEndArray() + } + + protected fun writeList(value: List<*>, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeStartArray() + for (item in value) { + item ?: continue + writeAny(item, gen, provider) + } + gen.writeEndArray() + } + + protected fun writeMap(value: Map<*, *>, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeStartObject() + for ((key, value) in value) { + key ?: continue + value ?: continue + gen.writeFieldName(key.toString()) + writeAny(value, gen, provider) + } + gen.writeEndObject() + } + + protected fun writeFieldMask(value: FieldMask, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeString(value.paths.joinToString(",")) + } +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/AnyExtension.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/AnyExtension.kt new file mode 100644 index 00000000..ba858177 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/AnyExtension.kt @@ -0,0 +1,21 @@ +package com.bybutter.sisyphus.protobuf.primitives + +import com.bybutter.sisyphus.protobuf.Message +import com.bybutter.sisyphus.protobuf.ProtoTypes + +/** + * Wrap message to [Any]. + */ +fun Message<*, *>.toAny(): Any { + return Any { + this.typeUrl = this@toAny.typeUrl() + this.value = this@toAny.toProto() + } +} + +/** + * Unwrap any. + */ +fun Any.toMessage(): Message<*, *> { + return ProtoTypes.ensureSupportByTypeUrl(this.typeUrl).parse(this.value) +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/EmptyExtension.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/EmptyExtension.kt new file mode 100644 index 00000000..6aecb52d --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/EmptyExtension.kt @@ -0,0 +1,8 @@ +package com.bybutter.sisyphus.protobuf.primitives + +/** + * Get the singleton empty message. + */ +val Empty.Companion.INSTANCE: Empty by lazy { + Empty {} +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/FieldMaskExtension.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/FieldMaskExtension.kt new file mode 100644 index 00000000..16f4528d --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/FieldMaskExtension.kt @@ -0,0 +1,130 @@ +package com.bybutter.sisyphus.protobuf.primitives + +import com.bybutter.sisyphus.protobuf.Message +import com.bybutter.sisyphus.protobuf.MutableMessage +import com.bybutter.sisyphus.protobuf.invoke +import java.util.SortedMap +import java.util.TreeMap + +operator fun , TM : MutableMessage> T.times(mask: FieldMask): T { + return FieldMaskTree(mask).applyTo(this) +} + +infix fun FieldMask.union(other: FieldMask): FieldMask { + return this + other +} + +operator fun FieldMask.Companion.invoke(vararg paths: String): FieldMask { + return FieldMask { + this.paths += paths.toList() + } +} + +operator fun FieldMask.plus(other: FieldMask): FieldMask { + return FieldMaskTree(this).let { + it.merge(FieldMaskTree(other)) + it.toFieldMask() + } +} + +class FieldMaskTree { + val children: SortedMap = TreeMap() + + constructor(vararg paths: String) : this(paths.asIterable()) + + constructor(mask: FieldMask) : this(mask.paths) + + constructor(paths: Iterable) { + for (path in paths) { + addPath(path) + } + } + + fun addPath(path: String) { + addPath(splitPath(path)) + } + + fun removePath(path: String) { + removePath(splitPath(path)) + } + + fun union(other: FieldMaskTree): FieldMaskTree { + return FieldMaskTree((this.getPaths().asSequence() + other.getPaths().asSequence()).asIterable()) + } + + fun merge(other: FieldMaskTree) { + for (path in getPaths()) { + addPath(path) + } + } + + fun , TM : MutableMessage> applyTo(proto: T): T { + return proto { + for ((field, value) in this) { + if (!this.has(field.number)) { + continue + } + + val tree = children[field.name] + when { + tree == null -> { + this.clear(field.number) + } + tree.children.isNotEmpty() -> { + this[field.number] = applyTo(value as T) + } + else -> { + } + } + } + } + } + + fun toFieldMask(): FieldMask { + return FieldMask { + paths += getPaths() + } + } + + fun getPaths(): List { + val result = mutableListOf() + for ((path, tree) in children) { + getPaths(path, result) + } + return result + } + + private fun getPaths(prefix: String, result: MutableList) { + if (children.isEmpty()) { + result.add(prefix) + return + } + + for ((path, tree) in children) { + tree.getPaths("$prefix.$path", result) + } + } + + private fun addPath(pathParts: List, index: Int = 0) { + if (index == pathParts.size) { + return + } + + val part = pathParts[index] + children.getOrPut(part) { + FieldMaskTree() + }.addPath(pathParts, index + 1) + } + + private fun removePath(pathParts: List, index: Int = 0) { + if (index == pathParts.size - 1) { + children.remove(pathParts[index]) + } + + children[pathParts[index]]?.removePath(pathParts, index + 1) + } + + private fun splitPath(path: String): List { + return path.split('.') + } +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/StructExtension.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/StructExtension.kt new file mode 100644 index 00000000..b2f24661 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/StructExtension.kt @@ -0,0 +1,95 @@ +package com.bybutter.sisyphus.protobuf.primitives + +import com.bybutter.sisyphus.protobuf.Message + +operator fun Struct.Companion.invoke(vararg pairs: Pair): Struct { + return Struct { + fields += pairs.associate { it.first to toStructValue(it.second) } + } +} + +operator fun Struct.Companion.invoke(data: Map): Struct { + return Struct { + fields += data.entries.associate { it.key to toStructValue(it.value) } + } +} + +operator fun Struct.Companion.invoke(data: Collection>): Struct { + return Struct { + fields += data.associate { it.first to toStructValue(it.second) } + } +} + +private fun toStructValue(value: kotlin.Any?): Value { + return when (value) { + is Number -> { + Value { + kind = Value.Kind.NumberValue(value.toDouble()) + } + } + is String -> { + Value { + kind = Value.Kind.StringValue(value) + } + } + is NullValue -> { + Value { + kind = Value.Kind.NullValue(value) + } + } + is Map<*, *> -> { + Value { + kind = Value.Kind.StructValue(Struct(value.entries.map { it.key.toString() to it.value })) + } + } + is List<*> -> { + Value { + kind = Value.Kind.ListValue(ListValue { + values += value.map { toStructValue(it) } + }) + } + } + is Boolean -> { + Value { + kind = Value.Kind.BoolValue(value) + } + } + is Struct -> { + Value { + kind = Value.Kind.StructValue(value) + } + } + is Value -> { + value + } + is Message<*, *> -> { + Value { + kind = Value.Kind.StructValue(value.asStruct()) + } + } + null -> { + Value { + kind = Value.Kind.NullValue(NullValue.NULL_VALUE) + } + } + else -> throw UnsupportedOperationException("Unsupported struct value.") + } +} + +fun Message<*, *>.asStruct(): Struct { + val result = mutableListOf>() + + for ((field, value) in this) { + val item = if (this.has(field.number)) { + field.name to value + } else { + null + } + + if (item != null) { + result.add(item) + } + } + + return Struct.invoke(result) +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/TimestampExtension.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/TimestampExtension.kt new file mode 100644 index 00000000..de2650a1 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/TimestampExtension.kt @@ -0,0 +1,294 @@ +package com.bybutter.sisyphus.protobuf.primitives + +import com.bybutter.sisyphus.protobuf.invoke +import com.bybutter.sisyphus.string.leftPadding +import com.bybutter.sisyphus.string.rightPadding +import java.math.BigInteger +import java.time.Instant +import java.time.Month +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeParseException +import java.util.concurrent.TimeUnit +import kotlin.math.abs +import kotlin.math.sign +import proto.internal.com.bybutter.sisyphus.protobuf.primitives.MutableDuration +import proto.internal.com.bybutter.sisyphus.protobuf.primitives.MutableTimestamp + +private const val nanosPerSecond = 1000000000L + +private val nanosPerSecondBigInteger = nanosPerSecond.toBigInteger() + +fun java.sql.Timestamp.toProto(): Timestamp = Timestamp { + seconds = TimeUnit.MILLISECONDS.toSeconds(this@toProto.time) + nanos = this@toProto.nanos +} + +fun Timestamp.toSql(): java.sql.Timestamp { + val result = java.sql.Timestamp(TimeUnit.SECONDS.toMillis(seconds)) + result.nanos = nanos + return result +} + +operator fun Timestamp.Companion.invoke(value: String): Timestamp { + return Timestamp { + val instant = ZonedDateTime.parse(value).toInstant() + seconds = instant.epochSecond + nanos = instant.nano + } +} + +fun Timestamp.Companion.tryParse(value: String): Timestamp? { + return try { + invoke(value) + } catch (e: DateTimeParseException) { + null + } +} + +operator fun Timestamp.Companion.invoke(year: Int, month: Month, day: Int, hour: Int = 0, minute: Int = 0, second: Int = 0, nano: Int = 0): Timestamp { + val instant = ZonedDateTime.of(year, month.value, day, hour, minute, second, nano, ZoneId.systemDefault()).toInstant() + return Timestamp { + this.seconds = instant.epochSecond + this.nanos = instant.nano + } +} + +operator fun Timestamp.Companion.invoke(year: Int, month: Int, day: Int, hour: Int = 0, minute: Int = 0, second: Int = 0, nano: Int = 0): Timestamp { + val instant = ZonedDateTime.of(year, month, day, hour, minute, second, nano, ZoneId.systemDefault()).toInstant() + return Timestamp { + this.seconds = instant.epochSecond + this.nanos = instant.nano + } +} + +operator fun Timestamp.Companion.invoke(instant: Instant): Timestamp { + return Timestamp { + this.seconds = instant.epochSecond + this.nanos = instant.nano + } +} + +fun Timestamp.Companion.now(): Timestamp { + val instant = Instant.now() + return Timestamp { + this.seconds = instant.epochSecond + this.nanos = instant.nano + } +} + +operator fun Timestamp.Companion.invoke(seconds: Long, nanos: Int = 0): Timestamp { + return Timestamp { + this.seconds = seconds + this.nanos = nanos + normalized() + } +} + +operator fun Timestamp.Companion.invoke(seconds: Double): Timestamp { + return Timestamp { + this.seconds = seconds.toLong() + this.nanos = ((seconds - seconds.toLong()) * seconds.sign * nanosPerSecond).toInt() + } +} + +operator fun Timestamp.Companion.invoke(nanos: BigInteger): Timestamp { + return Timestamp { + this.seconds = (nanos / nanosPerSecondBigInteger).toLong() + this.nanos = (nanos % nanosPerSecondBigInteger).toInt() + } +} + +private val durationRegex = """^(-)?([0-9]+)(?:\.([0-9]+))?s$""".toRegex() +operator fun Duration.Companion.invoke(value: String): Duration { + return tryParse(value) ?: throw IllegalArgumentException("Illegal duration value '$value'.") +} + +fun Duration.Companion.tryParse(value: String): Duration? { + val result = durationRegex.matchEntire(value) ?: return null + + val sign = if (result.groupValues[1].isEmpty()) 1 else -1 + val seconds = result.groupValues[2].toLong() * sign + val nanos = result.groupValues[3].rightPadding(9, '0').toInt() * sign + + return Duration(seconds, nanos) +} + +operator fun Duration.Companion.invoke(seconds: Long, nanos: Int = 0): Duration { + return Duration { + this.seconds = seconds + this.nanos = nanos + normalized() + } +} + +operator fun Duration.Companion.invoke(seconds: Double): Duration { + return invoke(seconds, TimeUnit.SECONDS) +} + +operator fun Duration.Companion.invoke(time: Double, unit: TimeUnit): Duration { + val nanos = (unit.toNanos(1) * time).toLong() + + return Duration { + this.seconds = nanos / nanosPerSecond + this.nanos = (nanos % nanosPerSecond).toInt() + } +} + +operator fun Duration.Companion.invoke(nanos: BigInteger): Duration { + return Duration { + this.seconds = (nanos / nanosPerSecondBigInteger).toLong() + this.nanos = (nanos % nanosPerSecondBigInteger).toInt() + } +} + +operator fun Duration.Companion.invoke(hours: Long, minutes: Long, seconds: Long, nanos: Int = 0): Duration { + return Duration((TimeUnit.HOURS.toNanos(hours) + TimeUnit.MINUTES.toNanos(minutes) + TimeUnit.SECONDS.toNanos(seconds) + nanos).toBigInteger()) +} + +fun Timestamp.toBigInteger(): BigInteger { + return BigInteger.valueOf(this.seconds) * BigInteger.valueOf(nanosPerSecond) + BigInteger.valueOf(this.nanos.toLong()) +} + +fun Timestamp.toTime(unit: TimeUnit): Long { + val nanos = seconds * nanosPerSecond + nanos + return unit.convert(nanos, TimeUnit.NANOSECONDS) +} + +fun Timestamp.toSeconds(): Long { + return toTime(TimeUnit.SECONDS) +} + +fun Timestamp.toInstant(): Instant { + return Instant.ofEpochSecond(this.seconds, this.nanos.toLong()) +} + +fun Duration.toBigInteger(): BigInteger { + return BigInteger.valueOf(this.seconds) * BigInteger.valueOf(nanosPerSecond) + BigInteger.valueOf(this.nanos.toLong()) +} + +fun Duration.toTime(unit: TimeUnit): Long { + val nanos = seconds * nanosPerSecond + nanos + return unit.convert(nanos, TimeUnit.NANOSECONDS) +} + +fun Duration.toSeconds(): Long { + return toTime(TimeUnit.SECONDS) +} + +operator fun Timestamp.plus(duration: Duration): Timestamp { + return this { + seconds += duration.seconds + nanos += duration.nanos + normalized() + } +} + +operator fun Timestamp.minus(duration: Duration): Timestamp { + return this { + seconds -= duration.seconds + nanos -= duration.nanos + normalized() + } +} + +operator fun Timestamp.minus(other: Timestamp): Duration { + return Duration(this.toBigInteger() - other.toBigInteger()) +} + +operator fun Timestamp.compareTo(other: Timestamp): Int { + if (this.seconds != other.seconds) { + return this.seconds.compareTo(other.seconds) + } + + return this.nanos.compareTo(other.nanos) +} + +fun Timestamp.string(): String { + return this.toInstant().toString() +} + +operator fun Duration.plus(duration: Duration): Duration { + return this { + seconds += duration.seconds + nanos += duration.nanos + normalized() + } +} + +operator fun Duration.minus(duration: Duration): Duration { + return this { + seconds -= duration.seconds + nanos -= duration.nanos + normalized() + } +} + +operator fun Duration.unaryPlus(): Duration { + return this {} +} + +operator fun Duration.unaryMinus(): Duration { + return this { + normalized() + seconds = -seconds + nanos = -nanos + } +} + +operator fun Duration.compareTo(other: Duration): Int { + if (this.seconds != other.seconds) { + return this.seconds.compareTo(other.seconds) + } + + return this.nanos.compareTo(other.nanos) +} + +fun Duration.string(): String = buildString { + append(this@string.seconds) + if (this@string.nanos != 0) { + append('.') + append(abs(this@string.nanos).toString().leftPadding(9, '0').trimEnd('0')) + } + append('s') +} + +fun abs(duration: Duration): Duration { + return duration { + normalized() + seconds = abs(seconds) + nanos = abs(nanos) + } +} + +private fun MutableTimestamp.normalized() { + if (seconds.sign == 0 || nanos.sign == 0) { + return + } + + if (seconds.sign != nanos.sign) { + seconds += nanos.sign + nanos = ((nanosPerSecond - abs(nanos)) * seconds.sign).toInt() + } + + if (nanos >= nanosPerSecond) { + seconds += nanos / nanosPerSecond + nanos %= nanosPerSecond.toInt() + } +} + +private fun MutableDuration.normalized() { + if (seconds.sign == 0 || nanos.sign == 0) { + return + } + + if (seconds.sign != nanos.sign) { + seconds += nanos.sign + nanos = ((nanosPerSecond - abs(nanos)) * seconds.sign).toInt() + } + + if (nanos >= nanosPerSecond) { + seconds += nanos / nanosPerSecond + nanos %= nanosPerSecond.toInt() + } +} diff --git a/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/TypeExtension.kt b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/TypeExtension.kt new file mode 100644 index 00000000..57c1a051 --- /dev/null +++ b/lib/sisyphus-protobuf/src/main/kotlin/com/bybutter/sisyphus/protobuf/primitives/TypeExtension.kt @@ -0,0 +1,130 @@ +package com.bybutter.sisyphus.protobuf.primitives + +import com.bybutter.sisyphus.protobuf.Message +import com.bybutter.sisyphus.protobuf.ProtoTypes + +fun DescriptorProto.toType(typeName: String): Type { + return Type { + val fileInfo = ProtoTypes.getFileDescriptorByName(typeName) + this.name = this@toType.name + this.fields += this@toType.field.map { it.toField() } + this.fields += ProtoTypes.getTypeExtensions(typeName).mapNotNull { ProtoTypes.getExtensionDescriptor(typeName, it)?.toField() } + this.oneofs += this@toType.oneofDecl.map { it.name } + this@toType.options?.let { + this.options += it.toOptions() + } + this.sourceContext = fileInfo?.let { + SourceContext { + this.fileName = it.name + } + } + this.syntax = when (fileInfo?.syntax) { + "proto3" -> Syntax.PROTO3 + else -> Syntax.PROTO2 + } + } +} + +fun FieldDescriptorProto.toField(): Field { + return Field { + this.kind = Field.Kind(this@toField.type.number) + this.cardinality = Field.Cardinality(this@toField.label.number) + this.number = this@toField.number + this.name = this@toField.name + when (this.kind) { + Field.Kind.TYPE_MESSAGE, + Field.Kind.TYPE_ENUM -> { + this.typeUrl = "types.bybutter.com/${this@toField.typeName.substring(1)}" + } + else -> { + } + } + if (this@toField.hasOneofIndex()) { + this.oneofIndex = this@toField.oneofIndex + } + this@toField.options?.packed?.let { + this.packed = it + } + this@toField.options?.let { + this.options += it.toOptions() + } + this.jsonName = this@toField.jsonName + if (this@toField.hasDefaultValue()) { + this.defaultValue = this@toField.defaultValue + } + } +} + +fun EnumDescriptorProto.toEnum(typeName: String): Enum { + return Enum { + val fileInfo = ProtoTypes.getFileDescriptorByName(typeName) + this.name = this@toEnum.name + this.enumvalue += this@toEnum.value.map { it.toEnumValue() } + this@toEnum.options?.let { + this.options += it.toOptions() + } + this.sourceContext = fileInfo?.let { + SourceContext { + this.fileName = it.name + } + } + this.syntax = when (fileInfo?.syntax) { + "proto3" -> Syntax.PROTO3 + else -> Syntax.PROTO2 + } + } +} + +fun EnumValueDescriptorProto.toEnumValue(): EnumValue { + return EnumValue { + this.number = this@toEnumValue.number + this.name = this@toEnumValue.name + this@toEnumValue.options?.let { + this.options += it.toOptions() + } + } +} + +private fun Message<*, *>.toOptions(): List