Bomboni provides comprehensive support for building gRPC services with Tonic. The framework includes request parsing, validation, authentication, error handling, and seamless integration with the request filtering system.
Overview
Key features for gRPC service development:
Request Parsing : Automatic validation and conversion with RequestParse derive macro
Authentication : JWT and custom authentication via context builders
Error Handling : Structured errors with automatic Status conversion
Filtering & Pagination : AIP-compliant list/search queries
Type Safety : Strong typing with protocol buffers
Quick Start
The Bookstore example demonstrates a complete gRPC service implementation:
bookstore/
├── bookstore-api/ # Protocol definitions and client/server types
├── bookstore-service/ # gRPC server implementation
└── bookstore-cli/ # Command-line client tool
Running the Example
# Start the service
cd examples/grpc/bookstore/bookstore-service
cargo run
# The service starts on 127.0.0.1:9000 with:
# - gRPC server with reflection enabled
# - JWT authentication (secret: test_secret_key)
# - In-memory data storage
# - Trace logging to stdout
Service Implementation
Main Server Setup
Here’s how to structure your gRPC server with all necessary components:
use std :: sync :: Arc ;
use tokio :: sync :: Mutex ;
use tonic :: transport :: Server ;
use tracing :: info;
use bomboni_common :: id :: worker :: WorkerIdGenerator ;
use bookstore_api :: v1 :: {
FILE_DESCRIPTOR_SET ,
author_service_server :: AuthorServiceServer ,
bookstore_service_server :: BookstoreServiceServer ,
};
use grpc_common :: auth :: {
authenticator :: AuthenticatorArc ,
context :: ContextBuilder ,
jwt_authenticator :: JwtAuthenticator ,
};
#[tokio :: main]
async fn main () -> AppResult <()> {
let config = AppConfig :: get ();
Tracer :: install_stdout () ? ;
// Initialize authentication
let authenticator : AuthenticatorArc = Arc :: new (
JwtAuthenticator :: new ( & config . auth . secret)
);
let context_builder = ContextBuilder :: new ( authenticator );
// Initialize ID generator for distributed systems
let id_generator = Arc :: new ( Mutex :: new (
WorkerIdGenerator :: new ( config . node . worker_number)
));
// Initialize repositories (in-memory for this example)
let author_repository = Arc :: new ( MemoryAuthorRepository :: new ());
let book_repository = Arc :: new ( MemoryBookRepository :: new ());
// Create service adapters
let author_adapter = AuthorAdapter :: new (
Arc :: clone ( & id_generator ),
context_builder . clone (),
AuthorQueryManager :: new ( Arc :: clone ( & author_repository )),
Arc :: clone ( & author_repository ),
);
let book_adapter = BookAdapter :: new (
context_builder . clone (),
BookQueryManager :: new ( Arc :: clone ( & book_repository )),
Arc :: clone ( & book_repository ),
Arc :: clone ( & id_generator ),
);
// Build and start gRPC server
let grpc_server = Server :: builder ()
. add_service (
tonic_reflection :: server :: Builder :: configure ()
. register_encoded_file_descriptor_set ( FILE_DESCRIPTOR_SET )
. build_v1alpha ()
. unwrap (),
)
. add_service ( AuthorServiceServer :: new ( author_adapter ))
. add_service ( BookstoreServiceServer :: new ( book_adapter ));
info! ( "gRPC server started at {}" , config . server . grpc_address);
grpc_server . serve ( config . server . grpc_address) . await ? ;
Ok (())
}
Request Parsing in gRPC
Service Adapter Pattern
Use the adapter pattern to implement gRPC services:
use bomboni_request :: parse :: RequestParse ;
use tonic :: { Request , Response , Status };
use bookstore_api :: v1 :: {
Book ,
GetBookRequest ,
bookstore_service_server :: BookstoreService ,
};
#[derive( Debug )]
pub struct BookAdapter {
context_builder : ContextBuilder ,
book_query_manager : BookQueryManager ,
create_book_command : CreateBookCommand ,
update_book_command : UpdateBookCommand ,
delete_book_command : DeleteBookCommand ,
}
impl BookAdapter {
pub fn new (
context_builder : ContextBuilder ,
book_query_manager : BookQueryManager ,
book_repository : BookRepositoryArc ,
id_generator : Arc < Mutex < WorkerIdGenerator >>,
) -> Self {
Self {
context_builder ,
book_query_manager ,
create_book_command : CreateBookCommand :: new (
Arc :: clone ( & id_generator ),
Arc :: clone ( & book_repository ),
),
update_book_command : UpdateBookCommand :: new (
Arc :: clone ( & book_repository )
),
delete_book_command : DeleteBookCommand :: new ( book_repository ),
}
}
}
Implementing gRPC Methods
#[tonic :: async_trait]
impl BookstoreService for BookAdapter {
#[tracing :: instrument]
async fn get_book (
& self ,
request : Request < GetBookRequest >
) -> Result < Response < Book >, Status > {
// Build authentication context from metadata
let _context = self . context_builder
. build_from_metadata ( request . metadata ());
// Parse and validate request
let request = ParsedGetBookRequest :: parse ( request . into_inner ()) ? ;
// Query data
let mut books = self . book_query_manager
. query_batch ( & [ request . id])
. await ? ;
Ok ( Response :: new ( books . remove ( 0 )))
}
#[tracing :: instrument]
async fn list_books (
& self ,
request : Request < ListBooksRequest >
) -> Result < Response < ListBooksResponse >, Status > {
let _context = self . context_builder
. build_from_metadata ( request . metadata ());
// Parse list query with filtering and pagination
let request = ParsedListBooksRequest :: parse_list_query (
request . into_inner (),
self . book_query_manager . list_query_builder (),
) ? ;
let book_list = self . book_query_manager
. query_list ( request . query, request . show_deleted)
. await ? ;
Ok ( Response :: new ( ListBooksResponse {
books : book_list . books,
next_page_token : book_list . next_page_token,
total_size : book_list . total_size,
}))
}
#[tracing :: instrument]
async fn create_book (
& self ,
request : Request < CreateBookRequest >
) -> Result < Response < Book >, Status > {
// Authentication required for write operations
let context = self . context_builder
. build_from_metadata ( request . metadata ());
let request = ParsedCreateBookRequest :: parse ( request . into_inner ()) ? ;
let book = self . create_book_command
. execute (
& context ,
CreateBookCommandInput {
display_name : & request . display_name,
author_id : request . author_id,
isbn : & request . isbn,
description : & request . description,
price_cents : request . price_cents,
page_count : request . page_count,
},
)
. await ?
. book;
Ok ( Response :: new ( book . into ()))
}
#[tracing :: instrument]
async fn delete_book (
& self ,
request : Request < DeleteBookRequest >
) -> Result < Response <()>, Status > {
let context = self . context_builder
. build_from_metadata ( request . metadata ());
let request = ParsedDeleteBookRequest :: parse ( request . into_inner ()) ? ;
self . delete_book_command
. execute ( & context , & request . id)
. await ? ;
Ok ( Response :: new (()))
}
}
Error Handling
Application Error Type
Define structured errors that automatically convert to gRPC Status:
use bomboni_request :: error :: { CommonError , RequestError };
use thiserror :: Error ;
use tonic :: { Code , Status };
use tracing :: error;
#[derive( Debug , Error )]
pub enum AppError {
#[error( "internal error: {0}" )]
Internal (#[from] Box < dyn std :: error :: Error + Send + Sync >),
#[error( "request error: {0}" )]
Request (#[from] RequestError ),
#[error( "status error: {0}" )]
Status (#[from] Status ),
}
pub type AppResult < T > = Result < T , AppError >;
// Automatic conversion to gRPC Status
impl From < AppError > for Status {
fn from ( err : AppError ) -> Self {
match err {
AppError :: Request ( err ) => err . into (),
AppError :: Status ( status ) => status ,
AppError :: Internal ( _ ) => {
error! ( "internal service error: {}" , err );
Self :: internal ( Code :: Internal . description ())
}
}
}
}
Error Code Mapping
The RequestError automatically maps to appropriate gRPC status codes:
INVALID_ARGUMENT - Validation failures, invalid filters
NOT_FOUND - Resource not found
UNAUTHENTICATED - Missing or invalid credentials
PERMISSION_DENIED - Authorization failures
INTERNAL - Unexpected server errors
Authentication
Context Builder Pattern
use grpc_common :: auth :: context :: ContextBuilder ;
use tonic :: Request ;
// Initialize once at startup
let context_builder = ContextBuilder :: new ( authenticator );
// Use in each request handler
#[tracing :: instrument]
async fn handle_request (
& self ,
request : Request < MyRequest >
) -> Result < Response < MyResponse >, Status > {
// Extract and validate authentication
let context = self . context_builder
. build_from_metadata ( request . metadata ());
// Use context for authorization checks
if ! context . is_authenticated () {
return Err ( Status :: unauthenticated ( "Authentication required" ));
}
// Continue with request processing...
}
JWT Authentication
use grpc_common :: auth :: jwt_authenticator :: JwtAuthenticator ;
// Configure JWT authenticator
let authenticator = JwtAuthenticator :: new ( "your_secret_key" );
// Or disable expiration validation for testing
let authenticator = JwtAuthenticator :: new_no_validation ( "test_secret_key" );
Request Validation
Using the Parse Derive Macro
Define parsed request types that automatically validate:
use bomboni_request_derive :: Parse ;
use bomboni_request :: parse :: RequestParse ;
use bomboni_common :: id :: Id ;
// Original protobuf request
#[derive( Clone , PartialEq , prost :: Message )]
pub struct GetBookRequest {
#[prost(string, tag = "1" )]
pub name : String , // Format: "books/{book_id}"
}
// Parsed and validated request
#[derive( Debug , Clone , PartialEq , Parse )]
#[parse(source = GetBookRequest , write)]
pub struct ParsedGetBookRequest {
#[parse(source = "name" , resource_name = { "books" : Id })]
pub id : Id ,
}
// Usage in service
let request = ParsedGetBookRequest :: parse ( raw_request ) ? ;
// request.id is now a validated Id type
Resource Name Parsing
Automatically parse hierarchical resource names:
use bomboni_request_derive :: Parse ;
#[derive( Parse )]
#[parse(source = CreateBookRequest )]
pub struct ParsedCreateBookRequest {
pub display_name : String ,
// Parses "authors/{author_id}" to Id
#[parse(resource_name = { "authors" : Id })]
pub author_id : Id ,
pub isbn : String ,
pub description : String ,
pub price_cents : i32 ,
pub page_count : i32 ,
}
List Queries
Implementing AIP-132 List Methods
use bomboni_request :: query :: list :: { ListQuery , ListQueryBuilder };
#[derive( Parse )]
#[parse(source = ListBooksRequest )]
pub struct ParsedListBooksRequest {
#[parse(list_query)]
pub query : ListQuery ,
#[parse(source = "show_deleted?" )]
pub show_deleted : bool ,
}
// In service implementation
let request = ParsedListBooksRequest :: parse_list_query (
raw_request ,
self . query_manager . list_query_builder (),
) ? ;
// request.query contains:
// - page_size: validated and clamped to limits
// - page_token: parsed and validated
// - filter: parsed CEL expression
// - ordering: parsed order specification
Filter Support
Clients can send complex filters:
# List books by author with price filter
grpcurl -d '{
"filter": "author = \"authors/42\" AND price_cents >= 1000",
"order_by": "display_name asc",
"page_size": 20
}' localhost:9000 bookstore.v1.BookstoreService/ListBooks
Testing
Using the CLI Client
The bookstore example includes a CLI for testing:
# Generate JWT token
TOKEN = $( cargo run -p bookstore-cli -- auth generate-token --quiet )
export BOOKSTORE_TOKEN = $TOKEN
# List all authors (no auth required)
cargo run -p bookstore-cli -- author list
# Create a new author (requires auth)
cargo run -p bookstore-cli -- author create "J.R.R. Tolkien" "[email protected] "
# Create a book for the author
cargo run -p bookstore-cli -- book create \
"The Hobbit" \
"authors/1" \
"A fantasy adventure novel"
# List books with filtering
cargo run -p bookstore-cli -- book list \
--filter 'author = "authors/1"' \
--order-by "display_name asc"
Integration Tests
use tonic :: Request ;
use bookstore_api :: v1 :: {
bookstore_service_client :: BookstoreServiceClient ,
CreateBookRequest ,
};
#[tokio :: test]
async fn test_create_book () -> Result <(), Box < dyn std :: error :: Error >> {
// Start test server
let addr = start_test_server () . await ? ;
// Connect client
let mut client = BookstoreServiceClient :: connect (
format! ( "http://{}" , addr )
) . await ? ;
// Create request with auth token
let mut request = Request :: new ( CreateBookRequest {
display_name : "Test Book" . to_string (),
author : "authors/1" . to_string (),
isbn : "978-0-123456-78-9" . to_string (),
description : "A test book" . to_string (),
price_cents : 1999 ,
page_count : 300 ,
});
request . metadata_mut () . insert (
"authorization" ,
format! ( "Bearer {}" , test_token ()) . parse () ? ,
);
// Execute request
let response = client . create_book ( request ) . await ? ;
let book = response . into_inner ();
assert_eq! ( book . display_name, "Test Book" );
assert! ( book . name . starts_with ( "books/" ));
Ok (())
}
Best Practices
Use the #[tracing::instrument] attribute on all service methods for automatic request tracing.
Always validate authentication context before executing commands that modify data.
Service Organization
Structure your services using the adapter/command pattern:
book/
├── adapter.rs # gRPC service implementation
├── query_manager.rs # Read operations
├── create_book_command.rs
├── update_book_command.rs
├── delete_book_command.rs
└── repository/
├── mod.rs
└── memory.rs # or postgres.rs, mysql.rs
Error Handling Strategy
Validation Errors : Return INVALID_ARGUMENT with descriptive messages
Not Found : Return NOT_FOUND for missing resources
Auth Errors : Return UNAUTHENTICATED or PERMISSION_DENIED
Internal Errors : Log details, return INTERNAL with generic message
Use Arc<Mutex<T>> for shared mutable state
Initialize expensive resources once at startup
Use connection pooling for databases
Enable gRPC compression for large responses
Implement batch query methods for related data
Request Parsing Learn about request validation and filtering
Database Integration Connect services to PostgreSQL or MySQL
Protocol Buffers Define gRPC service APIs
Common Types Use Id and UtcDateTime in services