Skip to content

Commit ff29603

Browse files
smaye81pkwarren
andauthored
Refactor validator creation to support various configurations (#283)
Fixes #51 This makes a series of changes to the way that a validator is created. Doing so enables more flexible ways to configure a validator and also provides a bit more safety when attempting to create a validator with invalid state. The impetus behind this change was to allow for the ability to specify a list of seed descriptors when building a validator and for the optional ability to disable lazy-loading of any descriptors not provided. Previously, a validator could be created either with a default constructor (`new Validator()`) or via a `Config` object (`new Validator(config)`). However, the `Config` object had no way to provide a list of descriptors, it only had a `disableLazy` field. Simply adding a list of descriptors to the `Config` presented a few problems though: * Since these were two unrelated fields in the config, users had a serious footgun to play with because it was possible to set `disableLazy` to true and not specify any descriptors. Doing so would return an error at validation time with a validation message about an unknown evaluator. * Even if the above was tolerable, building validation rules from descriptors necessitates catching a `CompilationException`. This meant that if the seed descriptors were added to the config, all users creating a validator would now have to catch a checked exception even if they weren't using the seed descriptors. The solution in this PR converts validator creation from using constructors into a builder pattern with the following changes: * `disableLazy` and the new `descriptors` list have been moved from the config and are now parameters at instantiation time. * There are two options for building once the builder is created: * `build()` * `buildWithDescriptors(List<Descriptor>, boolean disableLazy) throws CompilationException, InvalidStateException` The overload that accepts seed descriptors is the only one that throws a `CompilationException`. If users choose to use `build` with no arguments, no checked exception needs to be handled. Note that the above also allows us to provide a helpful check at instantiation time if a user sets `disableLazy` to true but doesn't provide any descriptors. In this case, an `IllegalStateException` is thrown. It is still possible to provide an _incorrect_ list of descriptors, but that is impossible to catch at instantiation time (but hopefully is now a bit harder to do). The new API now adds a `ValidatorFactory` class with static methods for creating a validator. In addition, the type returned is now an interface (still named `Validator`) which provides us with a bit more flexibility both for testing and for making non-breaking changes in the future. Examples of creating a validator with no seed descriptors: ```java // Create a Validator with a default config Validator validator = ValidatorFactory.newBuilder().build(); // Create a Validator with a custom config Config cfg = Config.newBuilder().setFailFast(true).build(); Validator validator = ValidatoryFactory.newBuilder().withConfig(cfg).build(); ``` Creating a validator with seed descriptors is similar. You would still call `newBuilder`, but at build time, you call `buildWithDescriptors` and pass the descriptors and set `disableLazy` accordingly. Using this approach requires catching exceptions at build time: ```java // Create a validator with seed descriptors MyMessage msg = MyMessage.newBuilder().build(); List<Descriptor> seedDescriptors = new ArrayList<Descriptor>(); seedDescriptors.add(msg.getDescriptorForType()); try { Validator validator = ValidatorFactory.newBuilder().buildWithDescriptors(seedDescriptors, false); // Note you can also create a validator with a config // Validator validator = ValidatorFactory.newBuilder().withConfig(cfg).buildWithDescriptors(seedDescriptors, false); // Also note that the buildWithDescriptors function is what throws the exception so the validator // builder can be created ahead of time with no need for catching an exception. } catch (CompilationException ce) { // Exception pre-warming the cache with the seed descriptors } catch (IllegalStateException ise) { // This is thrown if seedDescriptors is empty and disableLazy is true } ``` Note: **THIS IS A BREAKING CHANGE**, but the hope is that this makes it easier and more intuitive for creating a validator while providing future-proof flexibility. To migrate, users should make the following changes: **With no config** ```diff import build.buf.protovalidate.Validator; + import build.buf.protovalidate.ValidatorFactory; - Validator validator = new Validator(); + Validator validator = ValidatorFactory.newBuilder().build(); ``` **With config** ```diff import build.buf.protovalidate.Validator; + import build.buf.protovalidate.ValidatorFactory; Config cfg = Config.newBuilder().setFailFast(true).build(); - Validator validator = new Validator(cfg); + Validator validator = ValidatorFactory.newBuilder().withConfig(cfg).build(); ``` In addition, the `loadMessages` and `loadDescriptors` methods from the validator have been removed. Users should instead use the `newBuilder(List<Descriptor> descriptors, boolean disableLazy)` approach to load descriptors ahead of time. --------- Co-authored-by: Philip K. Warren <[email protected]>
1 parent 45271ab commit ff29603

File tree

12 files changed

+524
-123
lines changed

12 files changed

+524
-123
lines changed

conformance/src/main/java/build/buf/protovalidate/conformance/Main.java

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import build.buf.protovalidate.Config;
1818
import build.buf.protovalidate.ValidationResult;
1919
import build.buf.protovalidate.Validator;
20+
import build.buf.protovalidate.ValidatorFactory;
2021
import build.buf.protovalidate.exceptions.CompilationException;
2122
import build.buf.protovalidate.exceptions.ExecutionException;
2223
import build.buf.validate.ValidateProto;
@@ -60,12 +61,13 @@ static TestConformanceResponse testConformance(TestConformanceRequest request) {
6061
TypeRegistry typeRegistry = FileDescriptorUtil.createTypeRegistry(fileDescriptorMap.values());
6162
ExtensionRegistry extensionRegistry =
6263
FileDescriptorUtil.createExtensionRegistry(fileDescriptorMap.values());
63-
Validator validator =
64-
new Validator(
65-
Config.newBuilder()
66-
.setTypeRegistry(typeRegistry)
67-
.setExtensionRegistry(extensionRegistry)
68-
.build());
64+
Config cfg =
65+
Config.newBuilder()
66+
.setTypeRegistry(typeRegistry)
67+
.setExtensionRegistry(extensionRegistry)
68+
.build();
69+
Validator validator = ValidatorFactory.newBuilder().withConfig(cfg).build();
70+
6971
TestConformanceResponse.Builder responseBuilder = TestConformanceResponse.newBuilder();
7072
Map<String, TestResult> resultsMap = new HashMap<>();
7173
for (Map.Entry<String, Any> entry : request.getCasesMap().entrySet()) {

conformance/src/test/java/build/buf/protovalidate/ValidatorTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public class ValidatorTest {
6060
@BeforeEach
6161
public void setUp() {
6262
Config config = Config.newBuilder().build();
63-
validator = new Validator(config);
63+
validator = ValidatorFactory.newBuilder().withConfig(config).build();
6464
}
6565

6666
@Test

src/main/java/build/buf/protovalidate/Config.java

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,16 @@ public final class Config {
2424
ExtensionRegistry.getEmptyRegistry();
2525

2626
private final boolean failFast;
27-
private final boolean disableLazy;
2827
private final TypeRegistry typeRegistry;
2928
private final ExtensionRegistry extensionRegistry;
3029
private final boolean allowUnknownFields;
3130

3231
private Config(
3332
boolean failFast,
34-
boolean disableLazy,
3533
TypeRegistry typeRegistry,
3634
ExtensionRegistry extensionRegistry,
3735
boolean allowUnknownFields) {
3836
this.failFast = failFast;
39-
this.disableLazy = disableLazy;
4037
this.typeRegistry = typeRegistry;
4138
this.extensionRegistry = extensionRegistry;
4239
this.allowUnknownFields = allowUnknownFields;
@@ -60,15 +57,6 @@ public boolean isFailFast() {
6057
return failFast;
6158
}
6259

63-
/**
64-
* Checks if the configuration for disabling lazy evaluation is enabled.
65-
*
66-
* @return if disabling lazy evaluation is enabled
67-
*/
68-
public boolean isDisableLazy() {
69-
return disableLazy;
70-
}
71-
7260
/**
7361
* Gets the type registry used for reparsing protobuf messages.
7462
*
@@ -99,7 +87,6 @@ public boolean isAllowingUnknownFields() {
9987
/** Builder for configuration. Provides a forward compatible API for users. */
10088
public static final class Builder {
10189
private boolean failFast;
102-
private boolean disableLazy;
10390
private TypeRegistry typeRegistry = DEFAULT_TYPE_REGISTRY;
10491
private ExtensionRegistry extensionRegistry = DEFAULT_EXTENSION_REGISTRY;
10592
private boolean allowUnknownFields;
@@ -117,17 +104,6 @@ public Builder setFailFast(boolean failFast) {
117104
return this;
118105
}
119106

120-
/**
121-
* Set the configuration for disabling lazy evaluation.
122-
*
123-
* @param disableLazy the boolean for enabling
124-
* @return this builder
125-
*/
126-
public Builder setDisableLazy(boolean disableLazy) {
127-
this.disableLazy = disableLazy;
128-
return this;
129-
}
130-
131107
/**
132108
* Set the type registry for reparsing protobuf messages. This option should be set alongside
133109
* setExtensionRegistry to allow dynamic resolution of predefined rule extensions. It should be
@@ -187,7 +163,7 @@ public Builder setAllowUnknownFields(boolean allowUnknownFields) {
187163
* @return the configuration.
188164
*/
189165
public Config build() {
190-
return new Config(failFast, disableLazy, typeRegistry, extensionRegistry, allowUnknownFields);
166+
return new Config(failFast, typeRegistry, extensionRegistry, allowUnknownFields);
191167
}
192168
}
193169
}

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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,30 @@ class EvaluatorBuilder {
5959
* @param env The CEL environment for evaluation.
6060
* @param config The configuration to use for the evaluation.
6161
*/
62-
public EvaluatorBuilder(Env env, Config config) {
62+
EvaluatorBuilder(Env env, Config config) {
6363
this.env = env;
64-
this.disableLazy = config.isDisableLazy();
64+
this.disableLazy = false;
6565
this.rules = new RuleCache(env, config);
6666
}
6767

68+
/**
69+
* Constructs a new {@link EvaluatorBuilder}.
70+
*
71+
* @param env The CEL environment for evaluation.
72+
* @param config The configuration to use for the evaluation.
73+
*/
74+
EvaluatorBuilder(Env env, Config config, List<Descriptor> descriptors, boolean disableLazy)
75+
throws CompilationException {
76+
Objects.requireNonNull(descriptors, "descriptors must not be null");
77+
this.env = env;
78+
this.disableLazy = disableLazy;
79+
this.rules = new RuleCache(env, config);
80+
81+
for (Descriptor descriptor : descriptors) {
82+
this.build(descriptor);
83+
}
84+
}
85+
6886
/**
6987
* Returns a pre-cached {@link Evaluator} for the given descriptor or, if the descriptor is
7088
* unknown, returns an evaluator that always throws a {@link CompilationException}.
@@ -73,7 +91,7 @@ public EvaluatorBuilder(Env env, Config config) {
7391
* @return An evaluator for the descriptor type.
7492
* @throws CompilationException If an evaluator can't be created for the specified descriptor.
7593
*/
76-
public Evaluator load(Descriptor desc) throws CompilationException {
94+
Evaluator load(Descriptor desc) throws CompilationException {
7795
Evaluator evaluator = evaluatorCache.get(desc);
7896
if (evaluator == null && disableLazy) {
7997
return new UnknownDescriptorEvaluator(desc);

src/main/java/build/buf/protovalidate/Validator.java

Lines changed: 3 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -17,43 +17,10 @@
1717
import build.buf.protovalidate.exceptions.CompilationException;
1818
import build.buf.protovalidate.exceptions.ExecutionException;
1919
import build.buf.protovalidate.exceptions.ValidationException;
20-
import com.google.protobuf.Descriptors.Descriptor;
2120
import com.google.protobuf.Message;
22-
import java.util.ArrayList;
23-
import java.util.List;
24-
import org.projectnessie.cel.Env;
25-
import org.projectnessie.cel.Library;
26-
27-
/** Performs validation on any proto.Message values. The Validator is safe for concurrent use. */
28-
public class Validator {
29-
/** evaluatorBuilder is the builder used to construct the evaluator for a given message. */
30-
private final EvaluatorBuilder evaluatorBuilder;
31-
32-
/**
33-
* failFast indicates whether the validator should stop evaluating rules after the first
34-
* violation.
35-
*/
36-
private final boolean failFast;
37-
38-
/**
39-
* Constructs a new {@link Validator}.
40-
*
41-
* @param config specified configuration.
42-
*/
43-
public Validator(Config config) {
44-
Env env = Env.newEnv(Library.Lib(new ValidateLibrary()));
45-
this.evaluatorBuilder = new EvaluatorBuilder(env, config);
46-
this.failFast = config.isFailFast();
47-
}
48-
49-
/** Constructs a new {@link Validator} with a default configuration. */
50-
public Validator() {
51-
Config config = Config.newBuilder().build();
52-
Env env = Env.newEnv(Library.Lib(new ValidateLibrary()));
53-
this.evaluatorBuilder = new EvaluatorBuilder(env, config);
54-
this.failFast = config.isFailFast();
55-
}
5621

22+
/** A validator that can be used to validate messages */
23+
public interface Validator {
5724
/**
5825
* Checks that message satisfies its rules. Rules are defined within the Protobuf file as options
5926
* from the buf.validate package. A {@link ValidationResult} is returned which contains a list of
@@ -67,47 +34,5 @@ public Validator() {
6734
* @return the {@link ValidationResult} from the evaluation.
6835
* @throws ValidationException if there are any compilation or validation execution errors.
6936
*/
70-
public ValidationResult validate(Message msg) throws ValidationException {
71-
if (msg == null) {
72-
return ValidationResult.EMPTY;
73-
}
74-
Descriptor descriptor = msg.getDescriptorForType();
75-
Evaluator evaluator = evaluatorBuilder.load(descriptor);
76-
List<RuleViolation.Builder> result = evaluator.evaluate(new MessageValue(msg), failFast);
77-
if (result.isEmpty()) {
78-
return ValidationResult.EMPTY;
79-
}
80-
List<Violation> violations = new ArrayList<>(result.size());
81-
for (RuleViolation.Builder builder : result) {
82-
violations.add(builder.build());
83-
}
84-
return new ValidationResult(violations);
85-
}
86-
87-
/**
88-
* Loads messages that are expected to be validated, allowing the {@link Validator} to warm up.
89-
* Messages included transitively (i.e., fields with message values) are automatically handled.
90-
*
91-
* @param messages the list of {@link Message} to load.
92-
* @throws CompilationException if there are any compilation errors during warm-up.
93-
*/
94-
public void loadMessages(Message... messages) throws CompilationException {
95-
for (Message message : messages) {
96-
this.evaluatorBuilder.load(message.getDescriptorForType());
97-
}
98-
}
99-
100-
/**
101-
* Loads message descriptors that are expected to be validated, allowing the {@link Validator} to
102-
* warm up. Messages included transitively (i.e., fields with message values) are automatically
103-
* handled.
104-
*
105-
* @param descriptors the list of {@link Descriptor} to load.
106-
* @throws CompilationException if there are any compilation errors during warm-up.
107-
*/
108-
public void loadDescriptors(Descriptor... descriptors) throws CompilationException {
109-
for (Descriptor descriptor : descriptors) {
110-
this.evaluatorBuilder.load(descriptor);
111-
}
112-
}
37+
ValidationResult validate(Message msg) throws ValidationException;
11338
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright 2023-2024 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 build.buf.protovalidate.exceptions.CompilationException;
18+
import com.google.protobuf.Descriptors.Descriptor;
19+
import java.util.List;
20+
import org.jspecify.annotations.Nullable;
21+
22+
/**
23+
* ValidatorFactory is used to create a validator.
24+
*
25+
* <p>Validators can be created with an optional {@link Config} to customize behavior. They can also
26+
* be created with a list of seed descriptors to warmup the validator cache ahead of time as well as
27+
* an indicator to lazily-load any descriptors not provided into the cache.
28+
*/
29+
public final class ValidatorFactory {
30+
// Prevent instantiation
31+
private ValidatorFactory() {}
32+
33+
/** A builder class used for building a validator. */
34+
public static class ValidatorBuilder {
35+
/** The config object to use for instantiating a validator. */
36+
@Nullable private Config config;
37+
38+
/**
39+
* Create a validator with the given config
40+
*
41+
* @param config The {@link Config} to configure the validator.
42+
* @return The builder instance
43+
*/
44+
public ValidatorBuilder withConfig(Config config) {
45+
this.config = config;
46+
return this;
47+
}
48+
49+
// Prevent instantiation
50+
private ValidatorBuilder() {}
51+
52+
/**
53+
* Build a new validator
54+
*
55+
* @return A new {@link Validator} instance.
56+
*/
57+
public Validator build() {
58+
Config cfg = this.config;
59+
if (cfg == null) {
60+
cfg = Config.newBuilder().build();
61+
}
62+
return new ValidatorImpl(cfg);
63+
}
64+
65+
/**
66+
* Build the validator, warming up the cache with any provided descriptors.
67+
*
68+
* @param descriptors the list of descriptors to warm up the cache.
69+
* @param disableLazy whether to disable lazy loading of validation rules. When validation is
70+
* performed, a message's rules will be looked up in a cache. If they are not found, by
71+
* default they will be processed and lazily-loaded into the cache. Setting this to false
72+
* will not attempt to lazily-load descriptor information not found in the cache and
73+
* essentially makes the entire cache read-only, eliminating thread contention.
74+
* @return A new {@link Validator} instance.
75+
* @throws CompilationException If any of the given descriptors' validation rules fail
76+
* processing while warming up the cache.
77+
* @throws IllegalStateException If disableLazy is set to true and no descriptors are passed.
78+
*/
79+
public Validator buildWithDescriptors(List<Descriptor> descriptors, boolean disableLazy)
80+
throws CompilationException, IllegalStateException {
81+
if (disableLazy && (descriptors == null || descriptors.isEmpty())) {
82+
throw new IllegalStateException(
83+
"a list of descriptors is required when disableLazy is true");
84+
}
85+
86+
Config cfg = this.config;
87+
if (cfg == null) {
88+
cfg = Config.newBuilder().build();
89+
}
90+
return new ValidatorImpl(cfg, descriptors, disableLazy);
91+
}
92+
}
93+
94+
/**
95+
* Creates a new builder for a validator.
96+
*
97+
* @return A Validator builder
98+
*/
99+
public static ValidatorBuilder newBuilder() {
100+
return new ValidatorBuilder();
101+
}
102+
}

0 commit comments

Comments
 (0)