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 Connect interceptor.
  4. Testing your validation logic.

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

Prerequisites#

  • Install the Buf CLI. If you already have, run buf --version to verify that you're using at least 1.32.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:

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

Goal#

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:

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

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

Run the server#

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

$ ./gradlew run

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

INFO: Server started on port 50051

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

$ 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.

{
  "version": "1"
}

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

Explore quickstart code#

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.

Protobuf#

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;
}

YAML#

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.

Java#

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.

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.

Add Protovalidate dependency#

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:0.6.0 as a dependency.

    build.gradle
    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:v0.10.7
    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.

Add a standard rule#

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.

Enforce complex 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 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.

Compile Protobuf and Java#

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

$ buf generate

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

$ ./gradlew run

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

INFO: Server started on port 50051

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

$ 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:

{
  "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.

Add a Protovalidate 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 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:

    InvoiceService.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( new Validator() );
    +   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:

    $ ./gradlew run
    

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

    INFO: Server started on port 50051
    

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

$ 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": [
    {
      "fieldPath": "invoice.invoice_id",
      "constraintId": "string.uuid_empty",
      "message": "value is empty, which is not a valid UUID"
    },
    {
      "fieldPath": "invoice.line_items",
      "constraintId": "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:

$ 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": [
    {
      "fieldPath": "invoice.line_items",
      "constraintId": "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.

Test Protovalidate errors#

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 constraintId;
    public final String fieldPath;
    public final String message;

    public ViolationSpec(String constraintId, String fieldPath, String message) {
        this.constraintId = constraintId;
        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.

$  ./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

More testing examples

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.

Wrapping up#

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. Enabing their enforcement within an RPC API.
  4. Testing their functionality.

Further reading#