From 888a3a25e70b6b99215267f4b3a9ebbe7cf085a4 Mon Sep 17 00:00:00 2001 From: Sri Krishna Date: Thu, 4 Dec 2025 15:13:12 +0530 Subject: [PATCH 1/3] Support `cel_expression` Signed-off-by: Sri Krishna --- build.gradle.kts | 2 +- conformance/build.gradle.kts | 4 +- gradle.properties | 2 +- .../buf/protovalidate/EvaluatorBuilder.java | 43 +++++++++++++++---- .../resources/buf/validate/validate.proto | 33 ++++++++++++++ 5 files changed, 72 insertions(+), 12 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index b41f92e2..180f9e29 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -172,7 +172,7 @@ tasks.register("exportProtovalidateModule") { commandLine( buf.asPath, "export", - "buf.build/bufbuild/protovalidate:${project.findProperty("protovalidate.version")}", + "https://github.com/bufbuild/protovalidate.git#subdir=proto/protovalidate,ref=${project.findProperty("protovalidate.version")}", "--output", "src/main/resources", ) diff --git a/conformance/build.gradle.kts b/conformance/build.gradle.kts index 7ee5cd51..cfa25e63 100644 --- a/conformance/build.gradle.kts +++ b/conformance/build.gradle.kts @@ -70,7 +70,9 @@ tasks.register("generateConformance") { "generate", "--template", "${layout.buildDirectory.get()}/buf-gen-templates/buf.gen.yaml", - "buf.build/bufbuild/protovalidate-testing:${project.findProperty("protovalidate.version")}", + "https://github.com/bufbuild/protovalidate.git#subdir=proto/protovalidate-testing,ref=${project.findProperty( + "protovalidate.version", + )}", ) } diff --git a/gradle.properties b/gradle.properties index d9e13df0..6de1071a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ # Version of buf.build/bufbuild/protovalidate to use. -protovalidate.version = v1.0.0 +protovalidate.version = 774f3764e09fcfc921b3ef5a42271754f0b7063a # Arguments to the protovalidate-conformance CLI protovalidate.conformance.args = --strict_message --strict_error --expected_failures=expected-failures.yaml diff --git a/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java b/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java index b621e78d..60872a82 100644 --- a/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java +++ b/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java @@ -42,6 +42,8 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.jspecify.annotations.Nullable; /** A build-through cache of message evaluators keyed off the provided descriptor. */ @@ -50,6 +52,10 @@ final class EvaluatorBuilder { FieldPathUtils.fieldPathElement( FieldRules.getDescriptor().findFieldByNumber(FieldRules.CEL_FIELD_NUMBER)); + private static final FieldPathElement CEL_EXPRESSION_FIELD_PATH_ELEMENT = + FieldPathUtils.fieldPathElement( + FieldRules.getDescriptor().findFieldByNumber(FieldRules.CEL_EXPRESSION_FIELD_NUMBER)); + private volatile Map evaluatorCache = Collections.emptyMap(); private final Cel cel; @@ -187,7 +193,11 @@ private void buildMessage(Descriptor desc, MessageEvaluator msgEval) private void processMessageExpressions( Descriptor desc, MessageRules msgRules, MessageEvaluator msgEval, DynamicMessage message) throws CompilationException { - List celList = msgRules.getCelList(); + List celList = + Stream.concat( + expressionsToRules(msgRules.getCelExpressionList()).stream(), + msgRules.getCelList().stream()) + .collect(Collectors.toList()); if (celList.isEmpty()) { return; } @@ -196,7 +206,7 @@ private void processMessageExpressions( .addMessageTypes(message.getDescriptorForType()) .addVar(Variable.THIS_NAME, StructTypeReference.create(desc.getFullName())) .build(); - List compiledPrograms = compileRules(celList, finalCel, false); + List compiledPrograms = compileRules(celList, finalCel, null); if (compiledPrograms.isEmpty()) { throw new CompilationException("compile returned null"); } @@ -354,7 +364,8 @@ private void processFieldExpressions( FieldDescriptor fieldDescriptor, FieldRules fieldRules, ValueEvaluator valueEvaluatorEval) throws CompilationException { List rulesCelList = fieldRules.getCelList(); - if (rulesCelList.isEmpty()) { + List exprList = fieldRules.getCelExpressionList(); + if (rulesCelList.isEmpty() && exprList.isEmpty()) { return; } CelBuilder builder = cel.toCelBuilder(); @@ -367,7 +378,16 @@ private void processFieldExpressions( builder = builder.addMessageTypes(fieldDescriptor.getMessageType()); } Cel finalCel = builder.build(); - List compiledPrograms = compileRules(rulesCelList, finalCel, true); + List compiledPrograms = new ArrayList<>(); + if (!rulesCelList.isEmpty()) { + compiledPrograms.addAll(compileRules(rulesCelList, finalCel, CEL_FIELD_PATH_ELEMENT)); + } + if (!exprList.isEmpty()) { + compiledPrograms.addAll( + compileRules( + expressionsToRules(exprList), finalCel, CEL_EXPRESSION_FIELD_PATH_ELEMENT)); + } + if (!compiledPrograms.isEmpty()) { valueEvaluatorEval.append(new CelPrograms(valueEvaluatorEval, compiledPrograms)); } @@ -510,7 +530,8 @@ private void processRepeatedRules( valueEvaluatorEval.append(listEval); } - private static List compileRules(List rules, Cel cel, boolean isField) + private static List compileRules( + List rules, Cel cel, @Nullable FieldPathElement fieldPathElement) throws CompilationException { List expressions = Expression.fromRules(rules); List compiledPrograms = new ArrayList<>(); @@ -518,11 +539,9 @@ private static List compileRules(List rules, Cel cel, boo Expression expression = expressions.get(i); AstExpression astExpression = AstExpression.newAstExpression(cel, expression); @Nullable FieldPath rulePath = null; - if (isField) { + if (fieldPathElement != null) { rulePath = - FieldPath.newBuilder() - .addElements(CEL_FIELD_PATH_ELEMENT.toBuilder().setIndex(i)) - .build(); + FieldPath.newBuilder().addElements(fieldPathElement.toBuilder().setIndex(i)).build(); } try { compiledPrograms.add( @@ -538,5 +557,11 @@ private static List compileRules(List rules, Cel cel, boo } return compiledPrograms; } + + private static List expressionsToRules(List expressions) { + return expressions.stream() + .map(expr -> Rule.newBuilder().setExpression(expr).build()) + .collect(Collectors.toList()); + } } } diff --git a/src/main/resources/buf/validate/validate.proto b/src/main/resources/buf/validate/validate.proto index 519545dd..4578bdaa 100644 --- a/src/main/resources/buf/validate/validate.proto +++ b/src/main/resources/buf/validate/validate.proto @@ -109,6 +109,24 @@ message Rule { // MessageRules represents validation rules that are applied to the entire message. // It includes disabling options and a list of Rule messages representing Common Expression Language (CEL) validation rules. message MessageRules { + // `cel_expression` is a repeated field CEL expressions. Each expression specifies a validation + // rule to be applied to this message. These rules are written in Common Expression Language (CEL) syntax. + // + // This is a simplified form of the `cel` Rule field, where only `expression` is set. This allows for + // simpler syntax when defining CEL Rules where `id` and `message` are largely redundant. + // + // For more information, [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/). + // + // ```proto + // message MyMessage { + // // The field `foo` must be greater than 42. + // option (buf.validate.message).cel_expression = "this.foo > 42"; + // // The field `foo` must be less than 84. + // option (buf.validate.message).cel_expression = "this.foo < 84"; + // optional int32 foo = 1; + // } + // ``` + repeated string cel_expression = 5; // `cel` is a repeated field of type Rule. Each Rule specifies a validation rule to be applied to this message. // These rules are written in Common Expression Language (CEL) syntax. For more information, // [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/). @@ -201,6 +219,21 @@ message OneofRules { // FieldRules encapsulates the rules for each type of field. Depending on // the field, the correct set should be used to ensure proper validations. message FieldRules { + // `cel_expression` is a repeated field CEL expressions. Each expression specifies a validation + // rule to be applied to this message. These rules are written in Common Expression Language (CEL) syntax. + // + // This is a simplified form of the `cel` Rule field, where only `expression` is set. This allows for + // simpler syntax when defining CEL Rules where `id` and `message` are largely redundant. + // + // For more information, [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/). + // + // ```proto + // message MyMessage { + // // The field `value` must be greater than 42. + // optional int32 value = 1 [(buf.validate.field).cel_expression = "this > 42"]; + // } + // ``` + repeated string cel_expression = 28; // `cel` is a repeated field used to represent a textual expression // in the Common Expression Language (CEL) syntax. For more information, // [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/). From 883ecdb084776c6d4615d8c27dcec4b7b9bd9627 Mon Sep 17 00:00:00 2001 From: Sri Krishna Date: Fri, 5 Dec 2025 12:12:57 +0530 Subject: [PATCH 2/3] Add `FieldMask` rules Signed-off-by: Sri Krishna --- gradle.properties | 2 +- .../buf/protovalidate/DescriptorMappings.java | 2 + .../buf/protovalidate/EvaluatorBuilder.java | 2 +- .../resources/buf/validate/validate.proto | 149 +++++++++++++++++- 4 files changed, 148 insertions(+), 7 deletions(-) diff --git a/gradle.properties b/gradle.properties index 6de1071a..f01bf033 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ # Version of buf.build/bufbuild/protovalidate to use. -protovalidate.version = 774f3764e09fcfc921b3ef5a42271754f0b7063a +protovalidate.version = 8ed821b7c3ee9cad5d840a12ef32339af0dd2cbd # Arguments to the protovalidate-conformance CLI protovalidate.conformance.args = --strict_message --strict_error --expected_failures=expected-failures.yaml diff --git a/src/main/java/build/buf/protovalidate/DescriptorMappings.java b/src/main/java/build/buf/protovalidate/DescriptorMappings.java index c73b14be..3f14e3a5 100644 --- a/src/main/java/build/buf/protovalidate/DescriptorMappings.java +++ b/src/main/java/build/buf/protovalidate/DescriptorMappings.java @@ -92,6 +92,8 @@ final class DescriptorMappings { EXPECTED_WKT_RULES.put("google.protobuf.Any", FIELD_RULES_DESC.findFieldByName("any")); EXPECTED_WKT_RULES.put( "google.protobuf.Duration", FIELD_RULES_DESC.findFieldByName("duration")); + EXPECTED_WKT_RULES.put( + "google.protobuf.FieldMask", FIELD_RULES_DESC.findFieldByName("field_mask")); EXPECTED_WKT_RULES.put( "google.protobuf.Timestamp", FIELD_RULES_DESC.findFieldByName("timestamp")); } diff --git a/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java b/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java index 60872a82..ced03fc0 100644 --- a/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java +++ b/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java @@ -560,7 +560,7 @@ private static List compileRules( private static List expressionsToRules(List expressions) { return expressions.stream() - .map(expr -> Rule.newBuilder().setExpression(expr).build()) + .map(expr -> Rule.newBuilder().setId(expr).setExpression(expr).build()) .collect(Collectors.toList()); } } diff --git a/src/main/resources/buf/validate/validate.proto b/src/main/resources/buf/validate/validate.proto index 4578bdaa..7d50bbd8 100644 --- a/src/main/resources/buf/validate/validate.proto +++ b/src/main/resources/buf/validate/validate.proto @@ -18,6 +18,7 @@ package buf.validate; import "google/protobuf/descriptor.proto"; import "google/protobuf/duration.proto"; +import "google/protobuf/field_mask.proto"; import "google/protobuf/timestamp.proto"; option go_package = "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate"; @@ -113,7 +114,8 @@ message MessageRules { // rule to be applied to this message. These rules are written in Common Expression Language (CEL) syntax. // // This is a simplified form of the `cel` Rule field, where only `expression` is set. This allows for - // simpler syntax when defining CEL Rules where `id` and `message` are largely redundant. + // simpler syntax when defining CEL Rules where `id` and `message` derived from the `expression`. `id` will + // be same as the `expression`. // // For more information, [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/). // @@ -223,7 +225,8 @@ message FieldRules { // rule to be applied to this message. These rules are written in Common Expression Language (CEL) syntax. // // This is a simplified form of the `cel` Rule field, where only `expression` is set. This allows for - // simpler syntax when defining CEL Rules where `id` and `message` are largely redundant. + // simpler syntax when defining CEL Rules where `id` and `message` derived from the `expression`. `id` will + // be same as the `expression`. // // For more information, [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/). // @@ -233,7 +236,7 @@ message FieldRules { // optional int32 value = 1 [(buf.validate.field).cel_expression = "this > 42"]; // } // ``` - repeated string cel_expression = 28; + repeated string cel_expression = 29; // `cel` is a repeated field used to represent a textual expression // in the Common Expression Language (CEL) syntax. For more information, // [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/). @@ -346,6 +349,7 @@ message FieldRules { // Well-Known Field Types AnyRules any = 20; DurationRules duration = 21; + FieldMaskRules field_mask = 28; TimestampRules timestamp = 22; } @@ -3764,6 +3768,29 @@ message StringRules { } ]; + // `ulid` specifies that the field value must be a valid ULID (Universally Unique + // Lexicographically Sortable Identifier) as defined by the [ULID specification](https://github.com/ulid/spec). + // If the field value isn't a valid ULID, an error message will be generated. + // + // ```proto + // message MyString { + // // value must be a valid ULID + // string value = 1 [(buf.validate.field).string.ulid = true]; + // } + // ``` + bool ulid = 35 [ + (predefined).cel = { + id: "string.ulid" + message: "value must be a valid ULID" + expression: "!rules.ulid || this == '' || this.matches('^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$')" + }, + (predefined).cel = { + id: "string.ulid_empty" + message: "value is empty, which is not a valid ULID" + expression: "!rules.ulid || this != ''" + } + ]; + // `well_known_regex` specifies a common well-known pattern // defined as a regex. If the field value doesn't match the well-known // regex, an error message will be generated. @@ -3976,7 +4003,7 @@ message BytesRules { // the string. // If the field value doesn't meet the requirement, an error message is generated. // - // ```protobuf + // ```proto // message MyBytes { // // value does not contain \x02\x03 // optional bytes value = 1 [(buf.validate.field).bytes.contains = "\x02\x03"]; @@ -3991,7 +4018,7 @@ message BytesRules { // values. If the field value doesn't match any of the specified values, an // error message is generated. // - // ```protobuf + // ```proto // message MyBytes { // // value must in ["\x01\x02", "\x02\x03", "\x03\x04"] // optional bytes value = 1 [(buf.validate.field).bytes.in = {"\x01\x02", "\x02\x03", "\x03\x04"}]; @@ -4085,6 +4112,31 @@ message BytesRules { expression: "!rules.ipv6 || this.size() != 0" } ]; + + // `uuid` ensures that the field `value` encodes the 128-bit UUID data as + // defined by [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122#section-4.1.2). + // The field must contain exactly 16 bytes + // representing the UUID. If the field value isn't a valid UUID, an error + // message will be generated. + // + // ```proto + // message MyBytes { + // // value must be a valid UUID + // optional bytes value = 1 [(buf.validate.field).bytes.uuid = true]; + // } + // ``` + bool uuid = 15 [ + (predefined).cel = { + id: "bytes.uuid" + message: "value must be a valid UUID" + expression: "!rules.uuid || this.size() == 0 || this.size() == 16" + }, + (predefined).cel = { + id: "bytes.uuid_empty" + message: "value is empty, which is not a valid UUID" + expression: "!rules.uuid || this.size() != 0" + } + ]; } // `example` specifies values that the field may have. These values SHOULD @@ -4638,6 +4690,93 @@ message DurationRules { extensions 1000 to max; } +// FieldMaskRules describe rules applied exclusively to the `google.protobuf.FieldMask` well-known type. +message FieldMaskRules { + // `const` dictates that the field must match the specified value of the `google.protobuf.FieldMask` type exactly. + // If the field's value deviates from the specified value, an error message + // will be generated. + // + // ```proto + // message MyFieldMask { + // // value must equal ["a"] + // google.protobuf.FieldMask value = 1 [(buf.validate.field).field_mask.const = { + // paths: ["a"] + // }]; + // } + // ``` + optional google.protobuf.FieldMask const = 1 [(predefined).cel = { + id: "field_mask.const" + expression: "this != getField(rules, 'const') ? 'value must equal paths %s'.format([getField(rules, 'const').paths]) : ''" + }]; + + // `in` requires the field value to only contain paths matching specified + // values or their subpaths. + // If any of the field value's paths doesn't match the rule, + // an error message is generated. + // See: https://protobuf.dev/reference/protobuf/google.protobuf/#field-mask + // + // ```proto + // message MyFieldMask { + // // The `value` FieldMask must only contain paths listed in `in`. + // google.protobuf.FieldMask value = 1 [(buf.validate.field).field_mask = { + // in: ["a", "b", "c.a"] + // }]; + // } + // ``` + repeated string in = 2 [(predefined).cel = { + id: "field_mask.in" + expression: "!this.paths.all(p, p in getField(rules, 'in') || getField(rules, 'in').exists(f, p.startsWith(f+'.'))) ? 'value must only contain paths in %s'.format([getField(rules, 'in')]) : ''" + }]; + + // `not_in` requires the field value to not contain paths matching specified + // values or their subpaths. + // If any of the field value's paths matches the rule, + // an error message is generated. + // See: https://protobuf.dev/reference/protobuf/google.protobuf/#field-mask + // + // ```proto + // message MyFieldMask { + // // The `value` FieldMask shall not contain paths listed in `not_in`. + // google.protobuf.FieldMask value = 1 [(buf.validate.field).field_mask = { + // not_in: ["forbidden", "immutable", "c.a"] + // }]; + // } + // ``` + repeated string not_in = 3 [(predefined).cel = { + id: "field_mask.not_in" + expression: "!this.paths.all(p, !(p in getField(rules, 'not_in') || getField(rules, 'not_in').exists(f, p.startsWith(f+'.')))) ? 'value must not contain any paths in %s'.format([getField(rules, 'not_in')]) : ''" + }]; + + // `example` specifies values that the field may have. These values SHOULD + // conform to other rules. `example` values will not impact validation + // but may be used as helpful guidance on how to populate the given field. + // + // ```proto + // message MyFieldMask { + // google.protobuf.FieldMask value = 1 [ + // (buf.validate.field).field_mask.example = { paths: ["a", "b"] }, + // (buf.validate.field).field_mask.example = { paths: ["c.a", "d"] }, + // ]; + // } + // ``` + repeated google.protobuf.FieldMask example = 4 [(predefined).cel = { + id: "field_mask.example" + expression: "true" + }]; + + // Extension fields in this range that have the (buf.validate.predefined) + // option set will be treated as predefined field rules that can then be + // set on the field options of other fields to apply field rules. + // Extension numbers 1000 to 99999 are reserved for extension numbers that are + // defined in the [Protobuf Global Extension Registry][1]. Extension numbers + // above this range are reserved for extension numbers that are not explicitly + // assigned. For rules defined in publicly-consumed schemas, use of extensions + // above 99999 is discouraged due to the risk of conflicts. + // + // [1]: https://github.com/protocolbuffers/protobuf/blob/main/docs/options.md + extensions 1000 to max; +} + // TimestampRules describe the rules applied exclusively to the `google.protobuf.Timestamp` well-known type. message TimestampRules { // `const` dictates that this field, of the `google.protobuf.Timestamp` type, must exactly match the specified value. If the field value doesn't correspond to the specified timestamp, an error message will be generated. From b0649aad91b43ddff54f0c6c98807df457f4c69a Mon Sep 17 00:00:00 2001 From: Sri Krishna Date: Tue, 9 Dec 2025 23:46:34 +0530 Subject: [PATCH 3/3] Update to latest Signed-off-by: Sri Krishna --- build.gradle.kts | 2 +- conformance/build.gradle.kts | 4 +--- gradle.properties | 2 +- src/main/resources/buf/validate/validate.proto | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 180f9e29..b41f92e2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -172,7 +172,7 @@ tasks.register("exportProtovalidateModule") { commandLine( buf.asPath, "export", - "https://github.com/bufbuild/protovalidate.git#subdir=proto/protovalidate,ref=${project.findProperty("protovalidate.version")}", + "buf.build/bufbuild/protovalidate:${project.findProperty("protovalidate.version")}", "--output", "src/main/resources", ) diff --git a/conformance/build.gradle.kts b/conformance/build.gradle.kts index cfa25e63..7ee5cd51 100644 --- a/conformance/build.gradle.kts +++ b/conformance/build.gradle.kts @@ -70,9 +70,7 @@ tasks.register("generateConformance") { "generate", "--template", "${layout.buildDirectory.get()}/buf-gen-templates/buf.gen.yaml", - "https://github.com/bufbuild/protovalidate.git#subdir=proto/protovalidate-testing,ref=${project.findProperty( - "protovalidate.version", - )}", + "buf.build/bufbuild/protovalidate-testing:${project.findProperty("protovalidate.version")}", ) } diff --git a/gradle.properties b/gradle.properties index f01bf033..c0705016 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ # Version of buf.build/bufbuild/protovalidate to use. -protovalidate.version = 8ed821b7c3ee9cad5d840a12ef32339af0dd2cbd +protovalidate.version = v1.1.0 # Arguments to the protovalidate-conformance CLI protovalidate.conformance.args = --strict_message --strict_error --expected_failures=expected-failures.yaml diff --git a/src/main/resources/buf/validate/validate.proto b/src/main/resources/buf/validate/validate.proto index 7d50bbd8..eefab5fb 100644 --- a/src/main/resources/buf/validate/validate.proto +++ b/src/main/resources/buf/validate/validate.proto @@ -4706,7 +4706,7 @@ message FieldMaskRules { // ``` optional google.protobuf.FieldMask const = 1 [(predefined).cel = { id: "field_mask.const" - expression: "this != getField(rules, 'const') ? 'value must equal paths %s'.format([getField(rules, 'const').paths]) : ''" + expression: "this.paths != getField(rules, 'const').paths ? 'value must equal paths %s'.format([getField(rules, 'const').paths]) : ''" }]; // `in` requires the field value to only contain paths matching specified