Skip to content

Commit

Permalink
Support protovalidate validation (#259)
Browse files Browse the repository at this point in the history
Right now the validator eagerly converts the protokt message to a protobuf DynamicMessage. With bufbuild/protovalidate-java#132, this can [change to supply a wrapper](0cd3157) around the protokt message that only converts as requested by the validator.

Not sure if this module belongs at the top level or in third-party. In that case, maybe the gRPC modules also belong in third-party. For example, supposing protokt one day supports the [Connect](https://connectrpc.com/) protocol, that would also go in third-party, but it feels like a sibling to gRPC.

Fixes #207.
  • Loading branch information
andrewparmet authored Dec 20, 2024
1 parent a00a12b commit db30a7d
Show file tree
Hide file tree
Showing 16 changed files with 1,188 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ allprojects {
)
licenseHeaderFile(
rootProject.file("gradle/license-header-c-style"),
"(syntax )"
"(syntax |edition )"
)
}
}
Expand Down
6 changes: 6 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

[versions]
autoService = "1.1.1"
cel = "0.5.1"
grpc-java = "1.69.0"
grpc-kotlin = "1.4.1"
kotlinLogging = "7.0.0"
Expand All @@ -23,6 +24,8 @@ ktlint = "1.5.0"
protobuf-java = "4.29.1"
protobuf-js = "7.4.0"
protobufGradlePlugin = "0.9.4"
protovalidate = "0.9.0"
protovalidateJava = "0.4.2"
slf4j = "2.0.16"

# build
Expand All @@ -44,6 +47,7 @@ jmh = "1.37"
wire = "5.1.0"

# test
buf = "1.47.2"
classgraph = "4.8.179"
grpc-js = "1.12.4"
jackson = "2.18.1"
Expand All @@ -62,6 +66,7 @@ wire = { id = "com.squareup.wire", version.ref = "wire" }
[libraries]
autoService = { module = "com.google.auto.service:auto-service", version.ref = "autoService" }
autoServiceAnnotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoService" }
cel = { module = "org.projectnessie.cel:cel-tools", version.ref = "cel" }
grpc-kotlin-gen = { module = "io.grpc:protoc-gen-grpc-kotlin", version.ref = "grpc-kotlin" }
grpc-netty = { module = "io.grpc:grpc-netty", version.ref = "grpc-java" }
grpc-stub = { module = "io.grpc:grpc-stub", version.ref = "grpc-java" }
Expand All @@ -73,6 +78,7 @@ ktlintRuleSetStandard = { module = "com.pinterest.ktlint:ktlint-ruleset-standard
protobuf-gradlePlugin = { module = "com.google.protobuf:protobuf-gradle-plugin", version.ref = "protobufGradlePlugin" }
protobuf-java = { module ="com.google.protobuf:protobuf-java", version.ref = "protobuf-java" }
protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf-java" }
protovalidateJava = { module = "build.buf:protovalidate", version.ref = "protovalidateJava" }
slf4jSimple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" }

# build
Expand Down
12 changes: 11 additions & 1 deletion protokt-codegen/src/main/kotlin/protokt/v1/codegen/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

package protokt.v1.codegen

import com.google.protobuf.DescriptorProtos.Edition
import com.google.protobuf.ExtensionRegistry
import com.google.protobuf.compiler.PluginProtos.CodeGeneratorRequest
import com.google.protobuf.compiler.PluginProtos.CodeGeneratorResponse
Expand Down Expand Up @@ -64,7 +65,16 @@ private fun main(`in`: InputStream, out: OutputStream) {
val grpcKotlinFiles = generateGrpcKotlinStubs(params, req)

CodeGeneratorResponse.newBuilder()
.setSupportedFeatures(Feature.FEATURE_PROTO3_OPTIONAL.number.toLong())
.setSupportedFeatures(
(
Feature.FEATURE_PROTO3_OPTIONAL_VALUE or
Feature.FEATURE_SUPPORTS_EDITIONS_VALUE
).toLong()
)
// we don't support all of proto2 but we have to say we support it for protovalidate examples
.setMinimumEdition(Edition.EDITION_PROTO2_VALUE)
// we don't actually support 2023 yet but we have to say we support it for protovalidate examples
.setMaximumEdition(Edition.EDITION_2023_VALUE)
.addAllFile(files)
.addAllFile(grpcKotlinFiles)
.build()
Expand Down
8 changes: 8 additions & 0 deletions protokt-protovalidate/api/protokt-protovalidate.api
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
public final class protokt/v1/buf/validate/Validator {
public fun <init> ()V
public fun <init> (Lbuild/buf/protovalidate/Config;)V
public synthetic fun <init> (Lbuild/buf/protovalidate/Config;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun load (Lcom/google/protobuf/Descriptors$Descriptor;)V
public final fun validate (Lprotokt/v1/Message;)Lbuild/buf/protovalidate/ValidationResult;
}

28 changes: 28 additions & 0 deletions protokt-protovalidate/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2024 Toast, Inc.
*
* 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.
*/

plugins {
id("protokt.jvm-conventions")
}

enablePublishing()
trackKotlinApiCompatibility()

dependencies {
implementation(project(":protokt-reflect"))
implementation(kotlin("reflect"))
implementation(libs.cel)
implementation(libs.protovalidateJava)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2024 Toast, Inc.
*
* 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.
*/

package protokt.v1.buf.validate

import build.buf.protovalidate.Config
import build.buf.protovalidate.ValidationResult
import build.buf.protovalidate.internal.celext.ValidateLibrary
import build.buf.protovalidate.internal.evaluator.Evaluator
import build.buf.protovalidate.internal.evaluator.EvaluatorBuilder
import build.buf.protovalidate.internal.evaluator.MessageValue
import com.google.protobuf.Descriptors.Descriptor
import org.projectnessie.cel.Env
import org.projectnessie.cel.Library
import protokt.v1.Beta
import protokt.v1.GeneratedMessage
import protokt.v1.Message
import protokt.v1.google.protobuf.RuntimeContext
import protokt.v1.google.protobuf.toDynamicMessage
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import kotlin.reflect.full.findAnnotation

@Beta
class Validator @JvmOverloads constructor(
config: Config = Config.newBuilder().build()
) {
private val evaluatorBuilder = EvaluatorBuilder(Env.newEnv(Library.Lib(ValidateLibrary())), config)

private val failFast = config.isFailFast

private val evaluatorsByFullTypeName = ConcurrentHashMap<String, Evaluator>()
private val descriptors = Collections.newSetFromMap(ConcurrentHashMap<Descriptor, Boolean>())

@Volatile
private var runtimeContext = RuntimeContext(emptyList())

fun load(descriptor: Descriptor) {
doLoad(descriptor)
runtimeContext = RuntimeContext(descriptors)
}

private fun doLoad(descriptor: Descriptor) {
descriptors.add(descriptor)
evaluatorsByFullTypeName[descriptor.fullName] = evaluatorBuilder.load(descriptor)
descriptor.nestedTypes.forEach(::doLoad)
}

fun validate(message: Message): ValidationResult =
evaluatorsByFullTypeName
.getValue(message::class.findAnnotation<GeneratedMessage>()!!.fullTypeName)
.evaluate(MessageValue(message.toDynamicMessage(runtimeContext)), failFast)
}
4 changes: 3 additions & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,12 @@ include(
"protokt-codegen",
"protokt-core",
"protokt-core-lite",
"protokt-gradle-plugin",
"protokt-protovalidate",
"protokt-reflect",
"protokt-runtime",
"protokt-runtime-grpc",
"protokt-runtime-grpc-lite",
"protokt-gradle-plugin",

"grpc-kotlin-shim",

Expand Down Expand Up @@ -70,6 +71,7 @@ include(
"testing:protokt-generation",
"testing:protokt-generation-2",
"testing:protobuf-java",
"testing:protovalidate-conformance",
"testing:protobufjs",
"testing:testing-util",

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@

package protokt.v1.conformance

import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.EnumSource
import protokt.v1.testing.ProcessOutput
import protokt.v1.testing.projectRoot
import protokt.v1.testing.runCommand
import java.io.File
Expand Down Expand Up @@ -64,9 +64,15 @@ class ConformanceTest {
@EnumSource
fun `run conformance tests`(runner: ConformanceRunner) {
try {
command(runner)
.runCommand(projectRoot.toPath())
.orFail("Conformance tests failed", ProcessOutput.Src.ERR)
val output = command(runner).runCommand(projectRoot.toPath())
println(output.stderr)

assertThat(output.stderr).contains("CONFORMANCE SUITE PASSED")
val matches = " (\\d+) unexpected failures".toRegex().findAll(output.stderr).toList()
// the current implementation runs two conformance suites
assertThat(matches).hasSize(2)
matches.forEach { assertThat(it.groupValues[1].toInt()).isEqualTo(0) }
assertThat(output.exitCode).isEqualTo(0)
} catch (t: Throwable) {
if (failingTests.exists()) {
println("Failing tests:\n" + failingTests.readText())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* limitations under the License.
*/

syntax = "proto3";
edition = "2023";

package protokt.v1.testing;

Expand Down
105 changes: 105 additions & 0 deletions testing/protovalidate-conformance/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Copyright (c) 2024 Toast, Inc.
*
* 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.
*/

import com.google.protobuf.gradle.GenerateProtoTask
import com.google.protobuf.gradle.proto
import org.gradle.api.distribution.plugins.DistributionPlugin.TASK_INSTALL_NAME

plugins {
id("protokt.jvm-conventions")
application
}

localProtokt(false)

dependencies {
implementation(project(":protokt-protovalidate"))
implementation(project(":protokt-reflect"))
implementation(kotlin("reflect"))
implementation(libs.cel)
implementation(libs.classgraph)
implementation(libs.protovalidateJava)

testImplementation(project(":testing:testing-util"))
testImplementation(libs.truth)
}

sourceSets.main {
proto {
srcDir(project.layout.buildDirectory.file("protovalidate/export"))
}
}

val protovalidateVersion = libs.versions.protovalidate.get()
val gobin = project.layout.buildDirectory.file("gobin").get().asFile.absolutePath
val bufExecutable = project.layout.buildDirectory.file("gobin/buf").get().asFile
val conformanceExecutable = project.layout.buildDirectory.file("gobin/protovalidate-conformance").get().asFile

val installBuf =
tasks.register<Exec>("installBuf") {
environment("GOBIN", gobin)
outputs.file(bufExecutable)
commandLine("go", "install", "github.com/bufbuild/buf/cmd/buf@v${libs.versions.buf.get()}")
}

val downloadConformanceProtos =
tasks.register<Exec>("downloadConformanceProtos") {
dependsOn(installBuf)
commandLine(
bufExecutable,
"export",
"buf.build/bufbuild/protovalidate-testing:v$protovalidateVersion",
"--output=build/protovalidate/export"
)
}

tasks.withType<GenerateProtoTask> {
dependsOn(downloadConformanceProtos)
}

val installConformance =
tasks.register<Exec>("installProtovalidateConformance") {
environment("GOBIN", gobin)
outputs.file(conformanceExecutable)
commandLine(
"go",
"install",
"github.com/bufbuild/protovalidate/tools/protovalidate-conformance@v$protovalidateVersion"
)
}

val conformance =
tasks.register<Exec>("conformance") {
dependsOn(TASK_INSTALL_NAME, installConformance)
description = "Runs protovalidate conformance tests."
environment(
"JAVA_OPTS" to "-Xmx45M",
"GOMEMLIMIT" to "20MiB"
)
commandLine(
conformanceExecutable.absolutePath,
"--strict_message",
"--strict_error",
"--expected_failures",
"expected_failures.yaml",
project.layout.buildDirectory.dir("install/${project.name}/bin/${project.name}").get().asFile.absolutePath
)
}

tasks.test { dependsOn(conformance) }

application {
mainClass.set("protokt.v1.buf.validate.conformance.Main")
}
Loading

0 comments on commit db30a7d

Please sign in to comment.