Skip to main content
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:
main.rs
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:
adapter.rs
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

adapter.rs
#[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:
error.rs
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

  1. Validation Errors: Return INVALID_ARGUMENT with descriptive messages
  2. Not Found: Return NOT_FOUND for missing resources
  3. Auth Errors: Return UNAUTHENTICATED or PERMISSION_DENIED
  4. Internal Errors: Log details, return INTERNAL with generic message

Performance Tips

  • 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

Build docs developers (and LLMs) love