Skip to content

Protovalidate in gRPC and Java

This quickstart shows how to add Protovalidate to a Java RPC powered by gRPC:

  1. Adding the Protovalidate dependency.
  2. Annotating Protobuf files and regenerating code.
  3. Adding a gRPC interceptor.
  4. Testing your validation logic.

Just need an example? There's an example of Protovalidate for gRPC and Java in GitHub.

  • Install the Buf CLI. If you already have, run buf --version to verify that you're using at least 1.54.0.

  • Have git and Java 11+ installed and in your $PATH.

  • Clone the buf-examples repo and navigate to the protovalidate/grpc-java/start directory:

    Terminal window
    $ git clone https://github.com/bufbuild/buf-examples.git && cd buf-examples/protovalidate/grpc-java/start

This quickstart's CreateInvoice RPC doesn't have any input validation. Your goal is to pass a unit test verifying that you've added two validation rules using Protovalidate:

  1. Requests must provide an Invoice with a UUID invoice_id.
  2. Within the Invoice message, all of its repeated LineItem line_items must have unique combinations of product_id and unit_price.

Run the test now, and you can see that it fails:

Terminal window
$ ./gradlew test
InvoiceServerTest > InvoiceId is required FAILED
org.opentest4j.AssertionFailedError at InvoiceServerTest.java:117
InvoiceServerTest > Two line items cannot have the same product_id and unit price FAILED
org.opentest4j.AssertionFailedError at InvoiceServerTest.java:142

When this test passes, you've met your goal.

Before you begin to code, verify that the example is working. Compile and run the server using its Gradle wrapper:

Terminal window
$ ./gradlew run

After a few seconds, you should see that it has started:

Terminal window
INFO: Server started on port 50051

In a second terminal window, use buf curl to send an invalid CreateInvoiceRequest:

Terminal window
$ buf curl \
--data '{ "invoice": { "invoice_id": "" } }' \
--protocol grpc \
--http2-prior-knowledge \
http://localhost:50051/invoice.v1.InvoiceService/CreateInvoice

The server should respond with the version number of the invoice that was created, despite the invalid request. That's what you're here to fix.

Terminal window
{
"version": "1"
}

Before you start coding, take a few minutes to explore the code in the example.

This quickstart uses the example in grpc-java/start. Following standard Java project structure, source code is in src/main. Tests are in src/test.

The project provides a single unary RPC:

proto/invoice/v1/invoice_service.proto
// InvoiceService is a simple CRUD service for managing invoices.
service InvoiceService {
// CreateInvoice creates a new invoice.
rpc CreateInvoice(CreateInvoiceRequest) returns (CreateInvoiceResponse);
}

CreateInvoiceRequest includes an invoice field that's an Invoice message. An Invoice has a repeated field of type LineItem:

proto/invoice/v1/invoice.proto (excerpt)
message Invoice {
// invoice_id is a unique identifier for this invoice.
string invoice_id = 1;
// line_items represent individual items on this invoice.
repeated LineItem line_items = 4;
}
// LineItem is an individual good or service added to an invoice.
message LineItem {
// product_id is the unique identifier for the good or service on this line.
string product_id = 2;
// quantity is the unit count of the good or service provided.
uint64 quantity = 3;
}

When you add Protovalidate, you'll update the following files:

  • buf.yaml: Protovalidate must be added as a dependency.
  • buf.gen.yaml: To avoid a common Go issue in projects using the Buf CLI's managed mode, you'll see how to exclude Protovalidate from package renaming.

You'll be working in invoice.v1.InvoiceServer. It's an executable that runs a server on port 50051. You'll edit it to add a Protovalidate interceptor to gRPC.

src/main/java/invoice/v1/InvoiceService.java provides InvoiceService, an implementation of the generated InvoiceServiceGrpc.InvoiceServiceImplBase. Its createInvoice function sends back a static response.

Now that you know your way around the example code, it's time to integrate Protovalidate.

It's time to add Protovalidate to your project. It may be useful to read the Protovalidate overview and its quickstart before continuing.

Because Protovalidate is a publicly available Buf Schema Registry (BSR) module, it's simple to add it to any Buf CLI project.

  1. Open build.gradle.kts and verify that build.buf:protovalidate has already been added as a dependency. In your own projects, you'd need to add build.buf:protovalidate as a dependency.

    build.gradle.kts
    dependencies {
    implementation(libs.annotation.api)
    implementation(libs.protobuf.java)
    implementation(libs.protovalidate)
    // Code omitted for brevity
    }
  2. Add Protovalidate as a dependency to buf.yaml.

    buf.yaml
    # For details on buf.yaml configuration, visit https://buf.build/docs/configuration/v2/buf-yaml
    version: v2
    modules:
    - path: proto
    deps:
    - buf.build/bufbuild/protovalidate
    lint:
    use:
    - STANDARD
    breaking:
    use:
    - FILE
  3. Update dependencies with the Buf CLI. You'll be warned that Protovalidate is declared but unused. That's fine.

    Updating CLI dependencies
    $ buf dep update
    WARN Module buf.build/bufbuild/protovalidate is declared in your buf.yaml deps but is unused...
  4. Because this example uses managed mode, exclude Protovalidate from any updates to java_package.

    buf.gen.yaml
    version: v2
    inputs:
    - directory: src/main/proto
    plugins:
    - remote: buf.build/protocolbuffers/java:v29.3
    out: src/main/java
    - remote: buf.build/grpc/java:v1.70.0
    out: src/main/java
    managed:
    enabled: true
    override:
    - file_option: java_package_suffix
    value: gen
    - file_option: java_package_prefix
    value: ""
    disable:
    - file_option: java_package
    module: buf.build/bufbuild/protovalidate
  5. Verify that configuration is complete by running buf generate. It should complete with no error.

You'll now add a standard rule to proto/invoice.proto to require that the invoice_id field is a UUID. Start by importing Protovalidate:

proto/invoice/v1/invoice.proto
syntax = "proto3";
package invoice.v1;
import "buf/validate/validate.proto";
import "google/protobuf/timestamp.proto";

You could use the required rule to verify that requests provide this field, but Protovalidate makes it easy to do more specific validations. Use string.uuid to declare that invoice_id must be present and a valid UUID.

proto/invoice/v1/invoice.proto
// Invoice is a collection of goods or services sold to a customer.
message Invoice {
// invoice_id is a unique identifier for this invoice.
string invoice_id = 1;
string invoice_id = 1 [
(buf.validate.field).string.uuid = true
];
// account_id is the unique identifier for the account purchasing goods.
string account_id = 2;
// invoice_date is the date for an invoice. It should represent a date and
// have no values for time components.
google.protobuf.Timestamp invoice_date = 3;
// line_items represent individual items on this invoice.
repeated LineItem line_items = 4;
}

Learn more about string and standard rules.

In Invoice, the line_items field needs to meet two business rules:

  1. There should always be at least one LineItem.
  2. No two LineItems should ever share the same product_id and unit_price.

Protovalidate can enforce both of these rules by combining a standard rule with a custom rule written in Common Expression Language (CEL).

First, use the min_items standard rule to require at least one LineItem:

proto/invoice.proto
// Invoice is a collection of goods or services sold to a customer.
message Invoice {
// invoice_id is a unique identifier for this invoice.
string invoice_id = 1 [
(buf.validate.field).string.uuid = true
];
// account_id is the unique identifier for the account purchasing goods.
string account_id = 2;
// invoice_date is the date for an invoice. It should represent a date and
// have no values for time components.
google.protobuf.Timestamp invoice_date = 3;
// line_items represent individual items on this invoice.
repeated LineItem line_items = 4;
repeated LineItem line_items = 4 [
(buf.validate.field).repeated.min_items = 1
];
}

Next, use a CEL expression to add a custom rule. Use the map, string, and unique CEL functions to check that no combination of product_id and unit_price appears twice within the array of LineItems:

proto/invoice.proto
// Invoice is a collection of goods or services sold to a customer.
message Invoice {
// invoice_id is a unique identifier for this invoice.
string invoice_id = 1 [
(buf.validate.field).string.uuid = true
];
// account_id is the unique identifier for the account purchasing goods.
string account_id = 2;
// invoice_date is the date for an invoice. It should represent a date and
// have no values for time components.
google.protobuf.Timestamp invoice_date = 3;
// line_items represent individual items on this invoice.
repeated LineItem line_items = 4 [
(buf.validate.field).repeated.min_items = 1
(buf.validate.field).repeated.min_items = 1,
(buf.validate.field).cel = {
id: "line_items.logically_unique"
message: "line items must be unique combinations of product_id and unit_price"
expression: "this.map( it, it.product_id + '-' + string(it.unit_price) ).unique()"
}
];
}

You've added validation rules to your Protobuf. To enforce them, you still need to regenerate code and add a Protovalidate interceptor to your server.

Learn more about custom rules.

Next, compile your Protobuf and regenerate code, adding the Protovalidate options to all of your message descriptors:

Terminal window
$ buf generate

With regenerated code, your server should still compile and build. (If you're still running the server, stop it with Ctrl-c.)

Terminal window
$ ./gradlew run

After a few seconds, you should see that it has started:

Terminal window
INFO: Server started on port 50051

In a second terminal window, use buf curl to send the same invalid CreateInvoiceRequest:

Terminal window
$ buf curl \
--data '{ "invoice": { "invoice_id": "" } }' \
--protocol grpc \
--http2-prior-knowledge \
http://localhost:50051/invoice.v1.InvoiceService/CreateInvoice

The response may be a surprise: the server still considers the request valid and returns a version number for the new invoice:

Terminal window
{
"version": "1"
}

The RPC is still successful because no Connect or gRPC implementations automatically enforce Protovalidate rules. To enforce your validation rules, you'll need to add an interceptor.

The buf-examples repository provides a sample ValidationInterceptor class, a gRPC ServerInterceptor that's ready to use with Protovalidate. It inspects requests, runs Protovalidate, and returns a gRPC INVALID_ARGUMENT status on failure. Validation failure responses use the same response format as the Connect RPC Protovalidate interceptor.

Follow these steps to begin enforcing Protovalidate rules:

  1. In your first console window, use Ctrl-c to stop your server.

  2. In InvoiceServer, add necessary imports:

    InvoiceServer.java
    package invoice.v1;
    import buf.build.example.protovalidate.ValidationInterceptor;
    import build.buf.protovalidate.Validator;
    import build.buf.protovalidate.ValidatorFactory;
    import io.grpc.*;
    import io.grpc.protobuf.services.ProtoReflectionServiceV1;
  3. In InvoiceServer's main method, instantiate a ValidationInterceptor and use it when creating InvoiceService's serviceDefinition:

    InvoiceServer.java
    public static void main(String[] args) throws IOException, InterruptedException {
    final BindableService service = new InvoiceService();
    final ServerServiceDefinition serviceDefinition = ServerInterceptors.intercept(service);
    final ValidationInterceptor validationInterceptor = new ValidationInterceptor(ValidatorFactory.newBuilder().build());
    final ServerServiceDefinition serviceDefinition = ServerInterceptors.intercept(service, validationInterceptor);
    final InvoiceServer invoiceServer = new InvoiceServer(50051, InsecureServerCredentials.create(), serviceDefinition);
    invoiceServer.run();
    invoiceServer.awaitTermination();
    }
  4. Stop (Ctrl-c) and restart your server:

    Terminal window
    $ ./gradlew run

    After a few seconds, you should see that it has started:

    Terminal window
    INFO: Server started on port 50051

Now that you've added the Protovalidate interceptor and restarted your server, try the buf curl command again:

Terminal window
$ buf curl \
--data '{ "invoice": { "invoice_id": "" } }' \
--protocol grpc \
--http2-prior-knowledge \
http://localhost:50051/invoice.v1.InvoiceService/CreateInvoice

This time, you should receive a block of JSON representing Protovalidate's enforcement of your rules. In the abridged excerpt below, you can see that it contains details about every field that violated Protovalidate rules:

Protovalidate violations
{
"violations": [
{
"ruleId": "string.uuid_empty",
"message": "value is empty, which is not a valid UUID"
},
{
"ruleId": "repeated.min_items",
"message": "value must contain at least 1 item(s)"
}
]
}

Last, use buf curl to test the custom rule that checks for logically unique LineItems:

Terminal window
$ buf curl \
--data '{ "invoice": { "invoice_id": "079a91c2-cb8b-4f01-9cf9-1b9c0abdd6d2", "line_items": [{"product_id": "A", "unit_price": "1" }, {"product_id": "A", "unit_price": "1" }] } }' \
--protocol grpc \
--http2-prior-knowledge \
http://localhost:50051/invoice.v1.InvoiceService/CreateInvoice

You can see that this more complex expression is enforced at runtime:

Custom CEL rule violation
{
"violations": [
{
"ruleId": "line_items.logically_unique",
"message": "line items must be unique combinations of product_id and unit_price"
}
]
}

You've now added Protovalidate to a gRPC in Java, but buf curl isn't a great way to make sure you're meeting all of your requirements. Next, you'll see one way to verify Protovalidate rules in tests.

The starting code for this quickstart contains InvoiceServerTest, a JUnit 5 test. It starts a server with a Protovalidate interceptor and iterates through a series of test cases.

In the prior section, you saw that the violations list returned by Protovalidate follows a predictable structure. Each violation in the list is a Protobuf message named Violation, defined within Protovalidate itself.

The test already provides a convenient way to declare expected violations through a ViolationSpec class:

ViolationSpec in InvoiceServerTest.java
private static class ViolationSpec {
public final String ruleId;
public final String fieldPath;
public final String message;
public ViolationSpec(String ruleId, String fieldPath, String message) {
this.ruleId = ruleId;
this.fieldPath = fieldPath;
this.message = message;
}
}

Examine the highlighted lines in InvoiceServerTest, noting that the tests check for specific expected violations:

InvoiceServerTest.java
public class InvoiceServerTest {
// Code omitted for brevity
@Test
@DisplayName("A valid invoice passes validation")
public void testValidInvoice() {
Invoice invoice = newValidInvoice();
CreateInvoiceRequest req = CreateInvoiceRequest.newBuilder().setInvoice(invoice).build();
assertDoesNotThrow(() -> invoiceClient.createInvoice(req));
}
@Test
@DisplayName("InvoiceId is required")
public void testInvoiceIdIsRequired() {
Invoice invoice = Invoice.newBuilder().mergeFrom(newValidInvoice())
.setInvoiceId("")
.build();
CreateInvoiceRequest req = CreateInvoiceRequest.newBuilder().setInvoice(invoice).build();
StatusRuntimeException exception = assertThrows(StatusRuntimeException.class, () -> invoiceClient.createInvoice(req));
checkStatusRuntimeException(exception, List.of(
new ViolationSpec("string.uuid_empty", "invoice.invoice_id", "value is empty, which is not a valid UUID")
));
}
@Test
@DisplayName("Two line items cannot have the same product_id and unit price")
public void testTwoLineItemsCannotHaveTheSameProductIdAndUnitPrice() {
Invoice template = newValidInvoice();
Invoice invoice = Invoice.newBuilder().mergeFrom(template)
.setLineItems(1,
LineItem.newBuilder()
.mergeFrom(template.getLineItems(1))
.setLineItemId(template.getLineItems(0).getLineItemId())
.setUnitPrice(template.getLineItems(0).getUnitPrice())
)
.build();
CreateInvoiceRequest req = CreateInvoiceRequest.newBuilder().setInvoice(invoice).build();
StatusRuntimeException exception = assertThrows(StatusRuntimeException.class, () -> invoiceClient.createInvoice(req));
checkStatusRuntimeException(exception, List.of(
new ViolationSpec("line_items.logically_unique", "invoice.line_items", "line items must be unique combinations of product_id and unit_price")
));
}
// Code omitted for brevity
}

To check your work, run all tests.

Terminal window
$ ./gradlew test --rerun

If all tests pass, you've met your goal:

Test results
InvoiceServerTest > InvoiceId is required PASSED
InvoiceServerTest > Two line items cannot have the same product_id and unit price PASSED
InvoiceServerTest > A valid invoice passes validation PASSED

The finish directory contains a thorough test that you can use as an example for your own tests. Its invoice.proto file also contains extensive Protovalidate rules.

In this quickstart, you've learned the basics of working with Protovalidate:

  1. Adding Protovalidate to your project.
  2. Declaring validation rules in your Protobuf files.
  3. Enabling their enforcement within an RPC API.
  4. Testing their functionality.