Skip to content

Commit 0f7172b

Browse files
authored
Improve support for dynamic messages (#48)
Update protovalidate-java to determine if the field, message, or oneof options contains an unknown field for the protovalidate extension. If so, reparse the options type to correctly interpret the options and enable validation. This will enable protovalidate to run when the inputs are a FileDescriptorSet (with preserved options).
1 parent 2c3f092 commit 0f7172b

File tree

6 files changed

+179
-23
lines changed

6 files changed

+179
-23
lines changed

src/main/java/build/buf/protovalidate/exceptions/CompilationException.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@
1414

1515
package build.buf.protovalidate.exceptions;
1616

17-
/**
18-
* {@link CompilationException} extends {@link ValidationException} is returned when a constraint
19-
* fails to compile. This is a fatal error.
20-
*/
17+
/** CompilationException is returned when a constraint fails to compile. This is a fatal error. */
2118
public class CompilationException extends ValidationException {
2219
public CompilationException(String message) {
2320
super(message);
2421
}
22+
23+
public CompilationException(String message, Throwable cause) {
24+
super(message, cause);
25+
}
2526
}

src/main/java/build/buf/protovalidate/exceptions/ExecutionException.java

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,7 @@
1414

1515
package build.buf.protovalidate.exceptions;
1616

17-
/**
18-
* {@link ExecutionException} extends {@link ValidationException} is returned when a constraint
19-
* fails to execute. This is a fatal error.
20-
*/
17+
/** ExecutionException is returned when a constraint fails to execute. This is a fatal error. */
2118
public class ExecutionException extends ValidationException {
2219
public ExecutionException(String message) {
2320
super(message);

src/main/java/build/buf/protovalidate/exceptions/ValidationException.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@
1414

1515
package build.buf.protovalidate.exceptions;
1616

17-
/** Extends {@link Exception} is the base exception for all validation errors. */
17+
/** ValidationException is the base exception for all validation errors. */
1818
public class ValidationException extends Exception {
1919
public ValidationException(String message) {
2020
super(message);
2121
}
22+
23+
public ValidationException(String message, Throwable cause) {
24+
super(message, cause);
25+
}
2226
}

src/main/java/build/buf/protovalidate/internal/evaluator/ConstraintResolver.java

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import com.google.protobuf.Descriptors.Descriptor;
2424
import com.google.protobuf.Descriptors.FieldDescriptor;
2525
import com.google.protobuf.Descriptors.OneofDescriptor;
26+
import com.google.protobuf.ExtensionRegistry;
2627
import com.google.protobuf.InvalidProtocolBufferException;
2728
import com.google.protobuf.MessageLite;
2829

@@ -35,9 +36,13 @@ class ConstraintResolver {
3536
* @param desc the message descriptor.
3637
* @return the resolved {@link MessageConstraints}.
3738
*/
38-
MessageConstraints resolveMessageConstraints(Descriptor desc)
39+
MessageConstraints resolveMessageConstraints(Descriptor desc, ExtensionRegistry registry)
3940
throws InvalidProtocolBufferException, CompilationException {
4041
DescriptorProtos.MessageOptions options = desc.getOptions();
42+
// If the protovalidate message extension is unknown, reparse using extension registry.
43+
if (options.getUnknownFields().hasField(ValidateProto.message.getNumber())) {
44+
options = DescriptorProtos.MessageOptions.parseFrom(options.toByteString(), registry);
45+
}
4146
if (!options.hasExtension(ValidateProto.message)) {
4247
return MessageConstraints.getDefaultInstance();
4348
}
@@ -61,9 +66,13 @@ MessageConstraints resolveMessageConstraints(Descriptor desc)
6166
* @param desc the oneof descriptor.
6267
* @return the resolved {@link OneofConstraints}.
6368
*/
64-
OneofConstraints resolveOneofConstraints(OneofDescriptor desc)
69+
OneofConstraints resolveOneofConstraints(OneofDescriptor desc, ExtensionRegistry registry)
6570
throws InvalidProtocolBufferException, CompilationException {
6671
DescriptorProtos.OneofOptions options = desc.getOptions();
72+
// If the protovalidate oneof extension is unknown, reparse using extension registry.
73+
if (options.getUnknownFields().hasField(ValidateProto.oneof.getNumber())) {
74+
options = DescriptorProtos.OneofOptions.parseFrom(options.toByteString(), registry);
75+
}
6776
if (!options.hasExtension(ValidateProto.oneof)) {
6877
return OneofConstraints.getDefaultInstance();
6978
}
@@ -87,9 +96,13 @@ OneofConstraints resolveOneofConstraints(OneofDescriptor desc)
8796
* @param desc the field descriptor.
8897
* @return the resolved {@link FieldConstraints}.
8998
*/
90-
FieldConstraints resolveFieldConstraints(FieldDescriptor desc)
99+
FieldConstraints resolveFieldConstraints(FieldDescriptor desc, ExtensionRegistry registry)
91100
throws InvalidProtocolBufferException, CompilationException {
92101
DescriptorProtos.FieldOptions options = desc.getOptions();
102+
// If the protovalidate field option is unknown, reparse using extension registry.
103+
if (options.getUnknownFields().hasField(ValidateProto.field.getNumber())) {
104+
options = DescriptorProtos.FieldOptions.parseFrom(options.toByteString(), registry);
105+
}
93106
if (!options.hasExtension(ValidateProto.field)) {
94107
return FieldConstraints.getDefaultInstance();
95108
}

src/main/java/build/buf/protovalidate/internal/evaluator/EvaluatorBuilder.java

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,12 @@
4545

4646
/** A build-through cache of message evaluators keyed off the provided descriptor. */
4747
public class EvaluatorBuilder {
48-
private static final ExtensionRegistry extensionRegistry = ExtensionRegistry.newInstance();
48+
private static final ExtensionRegistry EXTENSION_REGISTRY = ExtensionRegistry.newInstance();
4949

5050
static {
51-
extensionRegistry.add(ValidateProto.message);
52-
extensionRegistry.add(ValidateProto.field);
53-
extensionRegistry.add(ValidateProto.oneof);
51+
EXTENSION_REGISTRY.add(ValidateProto.message);
52+
EXTENSION_REGISTRY.add(ValidateProto.field);
53+
EXTENSION_REGISTRY.add(ValidateProto.oneof);
5454
}
5555

5656
private final Map<Descriptor, Evaluator> evaluatorMap = new HashMap<>();
@@ -102,17 +102,18 @@ private Evaluator build(Descriptor desc) throws CompilationException {
102102
private void buildMessage(Descriptor desc, MessageEvaluator msgEval) throws CompilationException {
103103
try {
104104
DynamicMessage defaultInstance =
105-
DynamicMessage.parseFrom(desc, new byte[0], extensionRegistry);
105+
DynamicMessage.parseFrom(desc, new byte[0], EXTENSION_REGISTRY);
106106
Descriptor descriptor = defaultInstance.getDescriptorForType();
107-
MessageConstraints msgConstraints = resolver.resolveMessageConstraints(descriptor);
107+
MessageConstraints msgConstraints =
108+
resolver.resolveMessageConstraints(descriptor, EXTENSION_REGISTRY);
108109
if (msgConstraints.getDisabled()) {
109110
return;
110111
}
111112
processMessageExpressions(descriptor, msgConstraints, msgEval, defaultInstance);
112113
processOneofConstraints(descriptor, msgEval);
113114
processFields(descriptor, msgEval);
114115
} catch (InvalidProtocolBufferException e) {
115-
throw new CompilationException("failed to parse proto definition: " + desc.getFullName());
116+
throw new CompilationException("failed to parse proto definition: " + desc.getFullName(), e);
116117
}
117118
}
118119

@@ -142,7 +143,8 @@ private void processOneofConstraints(Descriptor desc, MessageEvaluator msgEval)
142143
throws InvalidProtocolBufferException, CompilationException {
143144
List<Descriptors.OneofDescriptor> oneofs = desc.getOneofs();
144145
for (Descriptors.OneofDescriptor oneofDesc : oneofs) {
145-
OneofConstraints oneofConstraints = resolver.resolveOneofConstraints(oneofDesc);
146+
OneofConstraints oneofConstraints =
147+
resolver.resolveOneofConstraints(oneofDesc, EXTENSION_REGISTRY);
146148
OneofEvaluator oneofEvaluatorEval =
147149
new OneofEvaluator(oneofDesc, oneofConstraints.getRequired());
148150
msgEval.append(oneofEvaluatorEval);
@@ -154,7 +156,8 @@ private void processFields(Descriptor desc, MessageEvaluator msgEval)
154156
List<FieldDescriptor> fields = desc.getFields();
155157
for (FieldDescriptor fieldDescriptor : fields) {
156158
FieldDescriptor descriptor = desc.findFieldByName(fieldDescriptor.getName());
157-
FieldConstraints fieldConstraints = resolver.resolveFieldConstraints(descriptor);
159+
FieldConstraints fieldConstraints =
160+
resolver.resolveFieldConstraints(descriptor, EXTENSION_REGISTRY);
158161
FieldEvaluator fldEval = buildField(descriptor, fieldConstraints);
159162
msgEval.append(fldEval);
160163
}
@@ -204,7 +207,7 @@ private void processFieldExpressions(
204207
try {
205208
DynamicMessage defaultInstance =
206209
DynamicMessage.parseFrom(
207-
fieldDescriptor.getMessageType(), new byte[0], extensionRegistry);
210+
fieldDescriptor.getMessageType(), new byte[0], EXTENSION_REGISTRY);
208211
opts =
209212
Arrays.asList(
210213
EnvOption.types(defaultInstance),
@@ -213,7 +216,7 @@ private void processFieldExpressions(
213216
Variable.THIS_NAME,
214217
Decls.newObjectType(fieldDescriptor.getMessageType().getFullName()))));
215218
} catch (InvalidProtocolBufferException e) {
216-
throw new CompilationException("field descriptor type is invalid " + e.getMessage());
219+
throw new CompilationException("field descriptor type is invalid " + e.getMessage(), e);
217220
}
218221
} else {
219222
opts =
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright 2023 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package build.buf.protovalidate;
16+
17+
import static org.assertj.core.api.Assertions.assertThat;
18+
19+
import build.buf.validate.Violation;
20+
import com.example.noimports.validationtest.ExampleFieldConstraints;
21+
import com.example.noimports.validationtest.ExampleMessageConstraints;
22+
import com.example.noimports.validationtest.ExampleOneofConstraints;
23+
import com.google.protobuf.DescriptorProtos;
24+
import com.google.protobuf.Descriptors;
25+
import com.google.protobuf.DynamicMessage;
26+
import com.google.protobuf.InvalidProtocolBufferException;
27+
import com.google.protobuf.Message;
28+
import java.util.LinkedHashSet;
29+
import java.util.Map;
30+
import java.util.Set;
31+
import java.util.function.Function;
32+
import java.util.stream.Collectors;
33+
import org.junit.Test;
34+
35+
/**
36+
* This test mimics the behavior when performing validation with protovalidate on a file descriptor
37+
* set (as created by <code>protoc --retain_options --descriptor_set_out=...</code>). These
38+
* descriptor types have the protovalidate extensions as unknown fields and need to be parsed with
39+
* an extension registry for the constraints to be recognized and validated.
40+
*/
41+
public class ValidatorDynamicMessageTest {
42+
43+
@Test
44+
public void testFieldConstraintDynamicMessage() throws Exception {
45+
DynamicMessage.Builder messageBuilder =
46+
createMessageWithUnknownOptions(ExampleFieldConstraints.getDefaultInstance());
47+
messageBuilder.setField(
48+
messageBuilder.getDescriptorForType().findFieldByName("regex_string_field"), "0123456789");
49+
Violation expectedViolation =
50+
Violation.newBuilder()
51+
.setConstraintId("string.pattern")
52+
.setFieldPath("regex_string_field")
53+
.setMessage("value does not match regex pattern `^[a-z0-9]{1,9}$`")
54+
.build();
55+
assertThat(new Validator().validate(messageBuilder.build()).getViolations())
56+
.containsExactly(expectedViolation);
57+
}
58+
59+
@Test
60+
public void testOneofConstraintDynamicMessage() throws Exception {
61+
DynamicMessage.Builder messageBuilder =
62+
createMessageWithUnknownOptions(ExampleOneofConstraints.getDefaultInstance());
63+
Violation expectedViolation =
64+
Violation.newBuilder()
65+
.setFieldPath("contact_info")
66+
.setConstraintId("required")
67+
.setMessage("exactly one field is required in oneof")
68+
.build();
69+
assertThat(new Validator().validate(messageBuilder.build()).getViolations())
70+
.containsExactly(expectedViolation);
71+
}
72+
73+
@Test
74+
public void testMessageConstraintDynamicMessage() throws Exception {
75+
DynamicMessage.Builder messageBuilder =
76+
createMessageWithUnknownOptions(ExampleMessageConstraints.getDefaultInstance());
77+
messageBuilder.setField(
78+
messageBuilder.getDescriptorForType().findFieldByName("secondary_email"),
79+
80+
Violation expectedViolation =
81+
Violation.newBuilder()
82+
.setConstraintId("secondary_email_depends_on_primary")
83+
.setMessage("cannot set a secondary email without setting a primary one")
84+
.build();
85+
assertThat(new Validator().validate(messageBuilder.build()).getViolations())
86+
.containsExactly(expectedViolation);
87+
}
88+
89+
private static void gatherDependencies(
90+
Descriptors.FileDescriptor fd, Set<DescriptorProtos.FileDescriptorProto> dependencies) {
91+
dependencies.add(fd.toProto());
92+
for (Descriptors.FileDescriptor dependency : fd.getDependencies()) {
93+
gatherDependencies(dependency, dependencies);
94+
}
95+
}
96+
97+
private static DescriptorProtos.FileDescriptorSet createFileDescriptorSetForMessage(
98+
Descriptors.Descriptor message) {
99+
DescriptorProtos.FileDescriptorSet.Builder builder =
100+
DescriptorProtos.FileDescriptorSet.newBuilder();
101+
Set<DescriptorProtos.FileDescriptorProto> dependencies = new LinkedHashSet<>();
102+
gatherDependencies(message.getFile(), dependencies);
103+
builder.addAllFile(dependencies);
104+
return builder.build();
105+
}
106+
107+
private static Descriptors.FileDescriptor getFileDescriptor(
108+
String name, Map<String, DescriptorProtos.FileDescriptorProto> fds)
109+
throws Descriptors.DescriptorValidationException {
110+
DescriptorProtos.FileDescriptorProto fdProto = fds.get(name);
111+
if (fdProto == null) {
112+
throw new IllegalArgumentException("unable to file file descriptor proto: " + name);
113+
}
114+
Descriptors.FileDescriptor[] dependencies =
115+
new Descriptors.FileDescriptor[fdProto.getDependencyCount()];
116+
for (int i = 0; i < fdProto.getDependencyCount(); i++) {
117+
dependencies[i] = getFileDescriptor(fdProto.getDependency(i), fds);
118+
}
119+
return Descriptors.FileDescriptor.buildFrom(fdProto, dependencies);
120+
}
121+
122+
private static DynamicMessage.Builder createMessageWithUnknownOptions(Message message)
123+
throws InvalidProtocolBufferException, Descriptors.DescriptorValidationException {
124+
DescriptorProtos.FileDescriptorSet fds =
125+
createFileDescriptorSetForMessage(message.getDescriptorForType());
126+
// Reparse file descriptor set from encoded form (loses known extensions).
127+
fds = DescriptorProtos.FileDescriptorSet.parseFrom(fds.toByteArray());
128+
Map<String, DescriptorProtos.FileDescriptorProto> fdsMap =
129+
fds.getFileList().stream()
130+
.collect(
131+
Collectors.toMap(
132+
DescriptorProtos.FileDescriptorProto::getName, Function.identity()));
133+
Descriptors.FileDescriptor descriptor =
134+
getFileDescriptor(message.getDescriptorForType().getFile().getName(), fdsMap);
135+
return DynamicMessage.newBuilder(
136+
descriptor.findMessageTypeByName(message.getDescriptorForType().getName()));
137+
}
138+
}

0 commit comments

Comments
 (0)