Skip to content

Commit

Permalink
Improve protovalidate with managed mode
Browse files Browse the repository at this point in the history
Update protovalidate to work with managed mode (without exceptions) and
'buf generate' as well as remote packages. Currently, those will
generate references to the protovalidate extensions to locations other
than the 'java_package' of 'build.buf.protovalidate' and when validation
is performed, an IllegalArgumentException is thrown.

Update ConstraintResolver to use getField instead of getExtension to
avoid the exception and transparently reparse extensions to the right
classes to be used by protovalidate-java.

Add a test exercising this behavior which generates code using managed
mode (without exceptions and '--include-imports') as well as one with
managed mode (with an exception).
  • Loading branch information
pkwarren committed Oct 10, 2023
1 parent 43658b3 commit 49e393e
Show file tree
Hide file tree
Showing 12 changed files with 321 additions and 17 deletions.
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ LICENSE_IGNORE := --ignore src/main/java/build/buf/validate/ --ignore conformanc
JAVA = java
GO ?= go
ARGS ?= --strict_message
# When updating, also update src/test/resources/proto/buf.yaml and re-run 'buf mod update'.
PROTOVALIDATE_VERSION ?= v0.4.3

.PHONY: all
Expand Down Expand Up @@ -56,6 +57,8 @@ generate: $(BIN)/buf generate-license ## Regenerate code and license headers
$(BIN)/buf export buf.build/bufbuild/protovalidate:$(PROTOVALIDATE_VERSION) --output src/main/resources
$(BIN)/buf generate --template buf.gen.yaml src/main/resources
$(BIN)/buf generate --template conformance/buf.gen.yaml -o conformance/ buf.build/bufbuild/protovalidate-testing:$(PROTOVALIDATE_VERSION)
$(BIN)/buf generate --template src/test/resources/proto/buf.gen.imports.yaml src/test/resources/proto --include-imports
$(BIN)/buf generate --template src/test/resources/proto/buf.gen.noimports.yaml src/test/resources/proto

.PHONY: lint
lint: ## Lint code
Expand Down
2 changes: 1 addition & 1 deletion buf.gen.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ version: v1
managed:
enabled: false
plugins:
- plugin: buf.build/protocolbuffers/java:v24.3
- plugin: buf.build/protocolbuffers/java:v24.4
out: src/main/java
10 changes: 9 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ tasks.withType<JavaCompile> {
options.compilerArgs = mutableListOf("--release", "8")
}
// Disable errorprone on generated code
options.errorprone.excludedPaths.set(".*/src/main/java/build/buf/validate/.*")
options.errorprone.excludedPaths.set("(.*/src/main/java/build/buf/validate/.*|.*/build/generated/.*)")
if (!name.lowercase().contains("test")) {
options.errorprone {
check("NullAway", CheckSeverity.ERROR)
Expand All @@ -49,6 +49,14 @@ buildscript {
}
}

sourceSets {
test {
java {
srcDir(layout.buildDirectory.dir("generated/test-sources/bufgen"))
}
}
}

apply(plugin = "com.diffplug.spotless")
configure<SpotlessExtension> {
java {
Expand Down
5 changes: 3 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
assertj = "3.24.2"
cel = "0.3.21"
junit = "4.13.2"
# When updating, make sure to update buf.gen.yaml version to match and regenerate code with 'make generate'.
protobuf = "3.24.3"
# When updating, make sure to update buf.gen.yaml and src/test/resources/proto/buf.gen.*.yaml versions to match
# and regenerate code with 'make generate'.
protobuf = "3.24.4"

[libraries]
assertj = { module = "org.assertj:assertj-core", version.ref = "assertj" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
import com.google.protobuf.Descriptors.Descriptor;
import com.google.protobuf.Descriptors.FieldDescriptor;
import com.google.protobuf.Descriptors.OneofDescriptor;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.MessageLite;

/** Manages the resolution of protovalidate constraints. */
class ConstraintResolver {
Expand All @@ -32,18 +34,24 @@ class ConstraintResolver {
* @param desc the message descriptor.
* @return the resolved {@link MessageConstraints}.
*/
MessageConstraints resolveMessageConstraints(Descriptor desc) {
MessageConstraints resolveMessageConstraints(Descriptor desc)
throws InvalidProtocolBufferException {
DescriptorProtos.MessageOptions options = desc.getOptions();
if (!options.hasExtension(ValidateProto.message)) {
return MessageConstraints.getDefaultInstance();
}

MessageConstraints constraints = options.getExtension(ValidateProto.message);
boolean disabled = constraints.getDisabled();
if (disabled) {
return MessageConstraints.newBuilder().setDisabled(true).build();
// Don't use getExtension here to avoid exception if descriptor types don't match.
// This can occur if the extension is generated to a different Java package.
Object value = options.getField(ValidateProto.message.getDescriptor());
if (value instanceof MessageConstraints) {
return ((MessageConstraints) value);
}
if (value instanceof MessageLite) {
// Possible that this represents the same constraint type, just generated to a different
// java_package.
return MessageConstraints.parseFrom(((MessageLite) value).toByteString());
}
return constraints;
return MessageConstraints.getDefaultInstance();
}

/**
Expand All @@ -52,12 +60,24 @@ MessageConstraints resolveMessageConstraints(Descriptor desc) {
* @param desc the oneof descriptor.
* @return the resolved {@link OneofConstraints}.
*/
OneofConstraints resolveOneofConstraints(OneofDescriptor desc) {
OneofConstraints resolveOneofConstraints(OneofDescriptor desc)
throws InvalidProtocolBufferException {
DescriptorProtos.OneofOptions options = desc.getOptions();
if (!options.hasExtension(ValidateProto.oneof)) {
return OneofConstraints.getDefaultInstance();
}
return options.getExtension(ValidateProto.oneof);
// Don't use getExtension here to avoid exception if descriptor types don't match.
// This can occur if the extension is generated to a different Java package.
Object value = options.getField(ValidateProto.oneof.getDescriptor());
if (value instanceof OneofConstraints) {
return ((OneofConstraints) value);
}
if (value instanceof MessageLite) {
// Possible that this represents the same constraint type, just generated to a different
// java_package.
return OneofConstraints.parseFrom(((MessageLite) value).toByteString());
}
return OneofConstraints.getDefaultInstance();
}

/**
Expand All @@ -66,11 +86,23 @@ OneofConstraints resolveOneofConstraints(OneofDescriptor desc) {
* @param desc the field descriptor.
* @return the resolved {@link FieldConstraints}.
*/
FieldConstraints resolveFieldConstraints(FieldDescriptor desc) {
FieldConstraints resolveFieldConstraints(FieldDescriptor desc)
throws InvalidProtocolBufferException {
DescriptorProtos.FieldOptions options = desc.getOptions();
if (!options.hasExtension(ValidateProto.field)) {
return FieldConstraints.getDefaultInstance();
}
return options.getExtension(ValidateProto.field);
// Don't use getExtension here to avoid exception if descriptor types don't match.
// This can occur if the extension is generated to a different Java package.
Object value = options.getField(ValidateProto.field.getDescriptor());
if (value instanceof FieldConstraints) {
return ((FieldConstraints) value);
}
if (value instanceof MessageLite) {
// Possible that this represents the same constraint type, just generated to a different
// java_package.
return FieldConstraints.parseFrom(((MessageLite) value).toByteString());
}
return FieldConstraints.getDefaultInstance();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ private void processMessageExpressions(
msgEval.append(new CelPrograms(compiledPrograms));
}

private void processOneofConstraints(Descriptor desc, MessageEvaluator msgEval) {
private void processOneofConstraints(Descriptor desc, MessageEvaluator msgEval)
throws InvalidProtocolBufferException {
List<Descriptors.OneofDescriptor> oneofs = desc.getOneofs();
for (Descriptors.OneofDescriptor oneofDesc : oneofs) {
OneofConstraints oneofConstraints = resolver.resolveOneofConstraints(oneofDesc);
Expand All @@ -149,7 +150,7 @@ private void processOneofConstraints(Descriptor desc, MessageEvaluator msgEval)
}

private void processFields(Descriptor desc, MessageEvaluator msgEval)
throws CompilationException {
throws CompilationException, InvalidProtocolBufferException {
List<FieldDescriptor> fields = desc.getFields();
for (FieldDescriptor fieldDescriptor : fields) {
FieldDescriptor descriptor = desc.findFieldByName(fieldDescriptor.getName());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Copyright 2023 Buf Technologies, 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 build.buf.protovalidate;

import static org.assertj.core.api.Assertions.assertThat;

import build.buf.protovalidate.exceptions.ValidationException;
import build.buf.validate.Violation;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.Message;
import java.util.Collections;
import java.util.List;
import org.junit.Test;

/**
* protovalidate-java contains protoc generated classes for the <a
* href="https://buf.build/bufbuild/protovalidate">bufbuild/protovalidate</a> module. In some cases
* however, using <code>buf generate</code> in managed mode (without <a
* href="https://buf.build/docs/configuration/v1/buf-gen-yaml#except-2">except</a>) or with remote
* packages will result in the generated code for this module being created in another package.
* While not desirable, we shouldn't fail with an exception in this case and should make a
* best-effort attempt to load the validation rules and validate them.
*
* <p>Prior to the fix, calling validate would fail up front with:
*
* <pre>IllegalArgumentException: mergeFrom(Message) can only merge messages of the same type.</pre>
*
* For the tests in this class, we've generated java code to two separate packages for the module
* found in <code>src/test/resources/proto</code>.
*
* <ol>
* <li><code>com.example.imports.*</code> is generated with managed mode and <code>
* --include-imports</code>, so it contains references to the bufbuild/protovalidate
* extensions under <code>com.example.imports.buf.validate.*</code>
* <li><code>com.example.noimports.*</code> is generated with managed mode and an exception for
* <code>buf.build/bufbuild/protovalidate</code>, so it contains references to the
* bufbuild/protovalidate extensions under <code>build.buf.validate.*</code> (honoring the
* <code>java_package</code> option).
* </ol>
*
* These tests ensure that the same classes can be validated identically regardless of where the
* protovalidate extensions are found.
*/
public class ValidatorDifferentJavaPackagesTest {
@Test
public void testValidationFieldConstraints() throws Exception {
// Valid message - matches regex
com.example.imports.validationtest.ExampleFieldConstraints validMsgImports =
com.example.imports.validationtest.ExampleFieldConstraints.newBuilder()
.setRegexStringField("abc123")
.build();
expectNoViolations(validMsgImports);

// Create same message under noimports package. Validation behavior should match.
com.example.noimports.validationtest.ExampleFieldConstraints validMsgNoImports =
com.example.noimports.validationtest.ExampleFieldConstraints.parseFrom(
validMsgImports.toByteString());
expectNoViolations(validMsgNoImports);

// 10 chars long - regex requires 1-9 chars
com.example.imports.validationtest.ExampleFieldConstraints invalidMsgImports =
com.example.imports.validationtest.ExampleFieldConstraints.newBuilder()
.setRegexStringField("0123456789")
.build();
Violation expectedViolation =
Violation.newBuilder()
.setConstraintId("string.pattern")
.setFieldPath("regex_string_field")
.setMessage("value does not match regex pattern `^[a-z0-9]{1,9}$`")
.build();
expectViolation(invalidMsgImports, expectedViolation);

// Create same message under noimports package. Validation behavior should match.
com.example.noimports.validationtest.ExampleFieldConstraints invalidMsgNoImports =
com.example.noimports.validationtest.ExampleFieldConstraints.newBuilder()
.setRegexStringField("0123456789")
.build();
expectViolation(invalidMsgNoImports, expectedViolation);
}

@Test
public void testValidationOneofConstraints()
throws ValidationException, InvalidProtocolBufferException {
// Valid message - matches oneof constraint
com.example.imports.validationtest.ExampleOneofConstraints validMsgImports =
com.example.imports.validationtest.ExampleOneofConstraints.newBuilder()
.setEmail("[email protected]")
.build();
expectNoViolations(validMsgImports);

// Create same message under noimports package. Validation behavior should match.
com.example.noimports.validationtest.ExampleOneofConstraints validMsgNoImports =
com.example.noimports.validationtest.ExampleOneofConstraints.parseFrom(
validMsgImports.toByteString());
expectNoViolations(validMsgNoImports);

com.example.imports.validationtest.ExampleOneofConstraints invalidMsgImports =
com.example.imports.validationtest.ExampleOneofConstraints.getDefaultInstance();
Violation expectedViolation =
Violation.newBuilder()
.setFieldPath("contact_info")
.setConstraintId("required")
.setMessage("exactly one field is required in oneof")
.build();
expectViolation(invalidMsgImports, expectedViolation);

// Create same message under noimports package. Validation behavior should match.
com.example.noimports.validationtest.ExampleOneofConstraints invalidMsgNoImports =
com.example.noimports.validationtest.ExampleOneofConstraints.parseFrom(
invalidMsgImports.toByteString());
expectViolation(invalidMsgNoImports, expectedViolation);
}

@Test
public void testValidationMessageConstraintsDifferentJavaPackage() throws Exception {
com.example.imports.validationtest.ExampleMessageConstraints validMsg =
com.example.imports.validationtest.ExampleMessageConstraints.newBuilder()
.setPrimaryEmail("[email protected]")
.build();
expectNoViolations(validMsg);

// Create same message under noimports package. Validation behavior should match.
com.example.noimports.validationtest.ExampleMessageConstraints validMsgNoImports =
com.example.noimports.validationtest.ExampleMessageConstraints.parseFrom(
validMsg.toByteString());
expectNoViolations(validMsgNoImports);

com.example.imports.validationtest.ExampleMessageConstraints invalidMsgImports =
com.example.imports.validationtest.ExampleMessageConstraints.newBuilder()
.setSecondaryEmail("[email protected]")
.build();
Violation expectedViolation =
Violation.newBuilder()
.setConstraintId("secondary_email_depends_on_primary")
.setMessage("cannot set a secondary email without setting a primary one")
.build();
expectViolation(invalidMsgImports, expectedViolation);

// Create same message under noimports package. Validation behavior should match.
com.example.noimports.validationtest.ExampleMessageConstraints invalidMsgNoImports =
com.example.noimports.validationtest.ExampleMessageConstraints.parseFrom(
invalidMsgImports.toByteString());
expectViolation(invalidMsgNoImports, expectedViolation);
}

private void expectNoViolations(Message msg) throws ValidationException {
expectViolations(msg, Collections.emptyList());
}

private void expectViolation(Message msg, Violation violation) throws ValidationException {
expectViolations(msg, Collections.singletonList(violation));
}

private void expectViolations(Message msg, List<Violation> expected) throws ValidationException {
Validator validator = new Validator();
List<Violation> violations = validator.validate(msg).getViolations();
assertThat(violations).containsExactlyInAnyOrderElementsOf(expected);
}
}
8 changes: 8 additions & 0 deletions src/test/resources/proto/buf.gen.imports.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version: v1
managed:
enabled: true
java_package_prefix:
default: com.example.imports
plugins:
- plugin: buf.build/protocolbuffers/java:v24.4
out: build/generated/test-sources/bufgen
10 changes: 10 additions & 0 deletions src/test/resources/proto/buf.gen.noimports.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: v1
managed:
enabled: true
java_package_prefix:
default: com.example.noimports
except:
- buf.build/bufbuild/protovalidate
plugins:
- plugin: buf.build/protocolbuffers/java:v24.4
out: build/generated/test-sources/bufgen
8 changes: 8 additions & 0 deletions src/test/resources/proto/buf.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Generated by buf. DO NOT EDIT.
version: v1
deps:
- remote: buf.build
owner: bufbuild
repository: protovalidate
commit: 63dfe56cc2c44cffa4815366ba7a99c0
digest: shake256:5a8a9856b92bf37171d45ccbe59fc48ea755cd682317b088cdf93e8e797ecf54039e012ece9a17084bb676e70c2e090296b2790782ebdbbedc7514e9b543e6bf
9 changes: 9 additions & 0 deletions src/test/resources/proto/buf.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
version: v1
breaking:
use:
- FILE
deps:
- buf.build/bufbuild/protovalidate:v0.4.3
lint:
use:
- DEFAULT
Loading

0 comments on commit 49e393e

Please sign in to comment.