Skip to content

Predefined rules#

When your Protovalidate projects grow, you might find that the same custom rules or groups of standard rules start to be repeated. Just like you'd refactor repeated code into a function, predefined rules allow you to write these patterns once and reuse them across your project.

Example case#

It's normal for a large schema to reuse the same maximum lengths for strings, such as 50, 100, and 250. Such fields are often either required or optional.

Consequently, Protobuf messages begin to repeat their Protovalidate rules:

Repeated groups of standard rules
message Person {
  // first_name is required and must be 50 or fewer characters.
  string first_name = 1 [
    (buf.validate.field).string.min_len = 1,
    (buf.validate.field).string.max_len = 50
  ];
  // middle_name is optional and must be 50 or fewer characters.
  string middle_name = 2 [(buf.validate.field).string.max_len = 50];
  // last_name is required and must be 50 or fewer characters.
  string last_name = 3 [
    (buf.validate.field).string.min_len = 1,
    (buf.validate.field).string.max_len = 50
  ];
  // title is optional and can be no longer than 64 characters.
  string title = 4 [(buf.validate.field).string.max_len = 64];
}

Instead of copying, pasting, and hoping for consistent maintenance, Protovalidate allows you to extend any standard rule message, capturing common logic once and reusing it.

Creating predefined rules#

With predefined rules, you can create two rules to address this example:

  • A string rule for a required "medium" length string.
  • A string rule for an optional "medium" length string.

Then, if the definition for a "medium" string changes from 50 characters to 64, you only need to make one update.

Create a rule file#

First, create a separate .proto file for predefined rules. It's not required, but separating services, messages, and extensions is good practice.

For the example above, create a predefined_string_rules.proto file to store all of your predefined string rules:

predefined_string_rules.proto
syntax = "proto2";

package bufbuild.people.v1;

import "buf/validate/validate.proto";

Because predefined rules are extensions, this file must use either proto2 syntax or Protobuf 2023 Edition. You're free to import and use them within proto3 files.

Extend a rule message#

Next, extend the desired standard rule message, like StringRules:

Extending StringRules
syntax = "proto2";

package bufbuild.people.v1;

import "buf/validate/validate.proto";

+ extend buf.validate.StringRules {}

Define rules#

For each predefined rule you want to create, add a field to the extension that follows these guidelines:

  • The field type should match the type of value for your rule. At runtime, its value is accessible within CEL expressions as a variable named rule.
  • The field number must not conflict with any other extension of the same message across all Protobuf files in the project. See the warning at the end of this section for more information.
  • The field must have an option of type buf.validate.predefined, which itself has a single cel field of type Rule. Its value is a custom CEL rule.

Following these guidelines, you can declare predefined required_medium and optional_medium rules to fix the example:

Simple predefined string rules
extend buf.validate.StringRules {
  optional bool required_medium = 80048952 [(buf.validate.predefined).cel = {
    id: "string.required.medium"
    message: "this is required and must be 50 or fewer characters"
    expression: "this.size() > 0 && this.size() <= 50"
  }];
  optional bool optional_medium = 80048953 [(buf.validate.predefined).cel = {
    id: "string.optional.medium"
    message: "this must be 50 or fewer characters"
    expression: "this.size() <= 50"
  }];
}

Field numbers must be unique#

Extension numbers may be from 1000 to 536870911, inclusive, and must not conflict with any other extension to the same message. This restriction also applies to projects that consume Protobuf files indirectly as dependencies.

For private Protobuf schemas, use 100000 to 536870911. For public schemas, use 1000 to 99999 and register your extension with the Protobuf Global Extension Registry. This prevents conflicts when your schemas are used as dependencies.

The same extension number may be re-used across different kinds of rule—1000 in FloatRules is distinct from 1000 in Int32Rules.

Applying predefined rules#

Now that you've defined required_medium and optional_medium rules, the repetitive groups of standard rules in the Person message can be simplified.

Be sure to import your rule file and surround the name of your extension with parentheses. Extensions are always qualified by the package within which they're defined. In this example, it's assumed that predefined rules are defined in the same package as messages.

In other cases, usage must qualify the package name of the extension. For example, (buf.validate.field).float.(foo.bar.required_with_max)

Using predefined rules in messages
syntax = "proto3";

package bufbuild.people.v1;

import "buf/validate/validate.proto";
import "bufbuild/people/v1/predefined_string_rules.proto";

message Person {
  string first_name = 1 [(buf.validate.field).string.(required_medium) = true];
  string middle_name = 2 [(buf.validate.field).string.(optional_medium) = true];
  string last_name = 3 [(buf.validate.field).string.(required_medium) = true];
  string title = 4 [(buf.validate.field).string.(optional_medium) = true];
}

Combining rules#

You can combine your predefined rule with other rules:

Combining predefined rules with standard rules
message Person {
  string email = 1 [
    (buf.validate.field).string.email = true,
    (buf.validate.field).string.(required_medium) = true
  ];
}

You can avoid repetition with message literal syntax:

Predefined rules with message literal syntax
message Person {
  string email = 1 [
    (buf.validate.field).string = {
      email: true,
      [bufbuild.people.v1.required_medium]: true
    }
  ];
}

Using rule values#

The prior example is a simple predefined rule: it doesn't use the rule's value within its CEL expression. If requirements for the title field changed to require a nonzero minimum length and an atypical maximum length like 64, it'd be tempting to stop using predefined rules.

Instead, you can create predefined rules that incorporate rule values into both their logic and validation messages. Building on the prior example, you can create a new required_with_max that:

  • Uses the rule variable within its CEL expression to access the value assigned to the rule (64).
  • Uses the rules variable within its CEL expression to resolve conflicts with other rules within the same underlying rule message.
  • Returns an empty string when the field's value is valid, and a dynamic error message when validation fails.
Complex predefined rule
extend buf.validate.StringRules {
  // Irrelevant rules omitted for brevity...
  optional int32 required_with_max = 80048954 [(buf.validate.predefined).cel = {
    id: "string.required.max"
    expression:
      "(this.size() > 0 && this.size() <= rule)"
      "? ''"
      ": 'this is required and must be ' + string(rule) + ' or fewer characters but ' + string(rules.max_len)"
  }];
}

You can now update title to use the required_with_max rule:

Using complex predefined rules in messages
message Person {
  // Irrelevant fields omitted for brevity...
  string title = 4 [(buf.validate.field).string.(required_with_max) = 64];
}

Logic in predefined rules may conflict with or overlap other rules. To resolve these cases, the rules variable is available within a predefined rule's CEL expression. Its value is an instance of the message extended by your predefined rule.

Using rules, the required_with_max rule could be updated to always pass validation whenever non-zero min_len and max_len rules are also applied to the field, delegating validation to these more specific rules:

Using the rules variable
extend buf.validate.StringRules {
  // Irrelevant rules omitted for brevity...
  optional int32 required_with_max = 80048954 [(buf.validate.predefined).cel = {
    id: "string.required.max"
    expression:
-     "(this.size() > 0 && this.size() <= rule)"
+     "(rules.min_len > 0 && rules.max_len > 0) || (this.size() > 0 && this.size() <= rule)"
      "? ''"
      ": 'this is required and must be ' + string(rule) + ' or fewer characters but ' + string(rules.max_len)"
  }];
}

Learn more#

Now that you've mastered standard rules, custom rules, and predefined rules, it's time to put Protovalidate to work inside your RPC APIs or Kafka streams: