Protovalidate in gRPC and Java
This quickstart shows how to add Protovalidate to a Java RPC powered by gRPC:
- Adding the Protovalidate dependency.
- Annotating Protobuf files and regenerating code.
- Adding a gRPC interceptor.
- Testing your validation logic.
Just need an example? There's an example of Protovalidate for gRPC and Java in GitHub.
Prerequisites
Section titled “Prerequisites”-
Install the Buf CLI. If you already have, run
buf --versionto verify that you're using at least1.54.0. -
Clone the
buf-examplesrepo and navigate to theprotovalidate/grpc-java/startdirectory: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:
- Requests must provide an
Invoicewith a UUIDinvoice_id. - Within the
Invoicemessage, all of itsrepeated LineItem line_itemsmust have unique combinations ofproduct_idandunit_price.
Run the test now, and you can see that it fails:
$ ./gradlew testInvoiceServerTest > InvoiceId is required FAILED org.opentest4j.AssertionFailedError at InvoiceServerTest.java:117InvoiceServerTest > Two line items cannot have the same product_id and unit price FAILED org.opentest4j.AssertionFailedError at InvoiceServerTest.java:142When this test passes, you've met your goal.
Run the server
Section titled “Run the server”Before you begin to code, verify that the example is working. Compile and run the server using its Gradle wrapper:
$ ./gradlew runAfter a few seconds, you should see that it has started:
INFO: Server started on port 50051In 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/CreateInvoiceThe 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
Section titled “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
Section titled “Protobuf”The project provides a single unary RPC:
// 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:
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.
Integrate Protovalidate
Section titled “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
Section titled “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.
-
Open
build.gradle.ktsand verify thatbuild.buf:protovalidatehas 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} -
Add Protovalidate as a dependency to
buf.yaml.buf.yaml # For details on buf.yaml configuration, visit https://buf.build/docs/configuration/v2/buf-yamlversion: v2modules:- path: protodeps:- buf.build/bufbuild/protovalidatelint:use:- STANDARDbreaking:use:- FILE -
Update dependencies with the Buf CLI. You'll be warned that Protovalidate is declared but unused. That's fine.
Updating CLI dependencies $ buf dep updateWARN Module buf.build/bufbuild/protovalidate is declared in your buf.yaml deps but is unused... -
Because this example uses managed mode, exclude Protovalidate from any updates to
java_package.buf.gen.yaml version: v2inputs:- directory: src/main/protoplugins:- remote: buf.build/protocolbuffers/java:v29.3out: src/main/java- remote: buf.build/grpc/java:v1.70.0out: src/main/javamanaged:enabled: trueoverride:- file_option: java_package_suffixvalue: gen- file_option: java_package_prefixvalue: ""disable:- file_option: java_packagemodule: buf.build/bufbuild/protovalidate -
Verify that configuration is complete by running
buf generate. It should complete with no error.
Add a standard rule
Section titled “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:
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.
// 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
Section titled “Enforce complex rules”In Invoice, the line_items field needs to meet two business rules:
- There should always be at least one
LineItem. - No two
LineItemsshould ever share the sameproduct_idandunit_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:
// 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:
// 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
Section titled “Compile Protobuf and Java”Next, compile your Protobuf and regenerate code, adding the Protovalidate options to all of your message descriptors:
$ buf generateWith regenerated code, your server should still compile and build. (If you're still running the server, stop it with Ctrl-c.)
$ ./gradlew runAfter a few seconds, you should see that it has started:
INFO: Server started on port 50051In 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/CreateInvoiceThe 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
Section titled “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:
-
In your first console window, use
Ctrl-cto stop your server. -
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; -
In
InvoiceServer'smainmethod, instantiate aValidationInterceptorand use it when creatingInvoiceService'sserviceDefinition: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();} -
Stop (
Ctrl-c) and restart your server:Terminal window $ ./gradlew runAfter 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:
$ buf curl \ --data '{ "invoice": { "invoice_id": "" } }' \ --protocol grpc \ --http2-prior-knowledge \ http://localhost:50051/invoice.v1.InvoiceService/CreateInvoiceThis 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:
{ "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:
$ 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/CreateInvoiceYou can see that this more complex expression is enforced at runtime:
{ "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.
Test Protovalidate errors
Section titled “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:
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:
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 --rerunIf all tests pass, you've met your goal:
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 PASSEDMore testing examples
Section titled “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
Section titled “Wrapping up”In this quickstart, you've learned the basics of working with Protovalidate:
- Adding Protovalidate to your project.
- Declaring validation rules in your Protobuf files.
- Enabling their enforcement within an RPC API.
- Testing their functionality.
Further reading
Section titled “Further reading”- Add Protovalidate's standard rules to schemas
- Use CEL expressions to declare field and message-level custom rules
- Reuse logic with predefined rules
- Learn more about Protovalidate's relationship with CEL.