Skip to content

Getting Started with gRPC

Quick Start: In 30 seconds, you'll have a working gRPC service handler.

# user_handler.py
from spikard import GrpcRequest, GrpcResponse
import user_pb2  # Generated from your .proto file

class UserServiceHandler:
    async def handle_request(self, request: GrpcRequest) -> GrpcResponse:
        if request.method_name == "GetUser":
            # Deserialize
            req = user_pb2.GetUserRequest()
            req.ParseFromString(request.payload)

            # Process
            user = user_pb2.User(id=req.user_id, name="Alice", email="alice@example.com")

            # Serialize and return
            return GrpcResponse(payload=user.SerializeToString())
// user_handler.ts
import { GrpcRequest, GrpcResponse } from '@spikard/node';
import { userservice } from './user_service';

export class UserServiceHandler {
  async handleRequest(request: GrpcRequest): Promise<GrpcResponse> {
    if (request.methodName === 'GetUser') {
      // Deserialize
      const req = userservice.v1.GetUserRequest.decode(request.payload);

      // Process
      const user = userservice.v1.User.create({
        id: req.userId,
        name: 'Alice',
        email: 'alice@example.com'
      });

      // Serialize and return
      const payload = userservice.v1.User.encode(user).finish();
      return new GrpcResponse({ payload });
    }
  }
}
# user_handler.rb
require 'spikard'
require_relative 'user_service_pb'

class UserServiceHandler
  def handle_request(request)
    if request.method_name == 'GetUser'
      # Deserialize
      req = Userservice::V1::GetUserRequest.decode(request.payload)

      # Process
      user = Userservice::V1::User.new(
        id: req.user_id,
        name: 'Alice',
        email: 'alice@example.com'
      )

      # Serialize and return
      Spikard::Grpc::Response.new(payload: user.to_proto)
    end
  end
end
<?php

declare(strict_types=1);

// UserServiceHandler.php
use Spikard\Grpc\Request;
use Spikard\Grpc\Response;
use Userservice\V1\GetUserRequest;
use Userservice\V1\User;

class UserServiceHandler
{
    public function handleRequest(Request $request): Response
    {
        if ($request->methodName === 'GetUser') {
            // Deserialize
            $req = new GetUserRequest();
            $req->mergeFromString($request->payload);

            // Process
            $user = new User();
            $user->setId($req->getUserId());
            $user->setName('Alice');
            $user->setEmail('alice@example.com');

            // Serialize and return
            return new Response(payload: $user->serializeToString());
        }
    }
}
use spikard_http::grpc::{GrpcHandler, GrpcRequestData, GrpcResponseData};
use prost::Message;
use tonic::Status;

mod userservice {
    include!("userservice.rs");  // Generated by prost
}

pub struct UserServiceHandler;

impl GrpcHandler for UserServiceHandler {
    async fn call(&self, request: GrpcRequestData) -> Result<GrpcResponseData, Status> {
        if request.method_name == "GetUser" {
            // Deserialize
            let req = userservice::GetUserRequest::decode(request.payload)
                .map_err(|e| Status::invalid_argument(e.to_string()))?;

            // Process
            let user = userservice::User {
                id: req.user_id,
                name: "Alice".to_string(),
                email: "alice@example.com".to_string(),
                ..Default::default()
            };

            // Serialize and return
            let mut buf = Vec::new();
            user.encode(&mut buf)
                .map_err(|e| Status::internal(e.to_string()))?;
            Ok(GrpcResponseData::new(buf.into()))
        } else {
            Err(Status::unimplemented("Method not found"))
        }
    }
}

That's it! Now let's build a complete gRPC service from scratch.


What is gRPC in Spikard?

Spikard's gRPC support lets you write type-safe service handlers in Python, TypeScript, Ruby, PHP, or Rust that integrate with a high-performance Rust runtime. You write handlers in your language of choice, Spikard handles the protocol details.

Architecture

+-------------------------------------------------------------+
|  Your Handler (Python, TypeScript, Ruby, PHP, Rust)         |
|  - Implements: handle_request(GrpcRequest) -> GrpcResponse  |
+-------------------------------------------------------------+
                            |
                            v
+-------------------------------------------------------------+
|  FFI Layer (spikard-py, spikard-node, spikard-rb, etc.)    |
|  - Binary protobuf payloads, metadata conversion            |
+-------------------------------------------------------------+
                            |
                            v
+-------------------------------------------------------------+
|  Rust Runtime (spikard-http + Tonic)                        |
|  - HTTP/2, gRPC protocol, routing, status codes             |
+-------------------------------------------------------------+

Key insight: Your handler receives raw protobuf bytes and returns raw protobuf bytes. Spikard handles HTTP/2, routing, and status codes.


Prerequisites

Required Tools

  1. protoc (Protocol Buffers compiler):

    # macOS
    brew install protobuf
    
    # Ubuntu/Debian
    apt-get install protobuf-compiler
    
    # Verify installation
    protoc --version  # Should be 3.0+
    

  2. Spikard CLI:

    cargo install spikard-cli
    

  3. Language-specific protobuf runtime:

pip install protobuf  # or: uv add protobuf
npm install protobufjs  # or: pnpm add protobufjs
gem install google-protobuf
composer require google/protobuf
cargo add prost prost-types

Step-by-Step Tutorial

Step 1: Write a .proto File

Create user_service.proto:

syntax = "proto3";

package userservice;

service UserService {
  rpc GetUser(GetUserRequest) returns (User);
  rpc CreateUser(CreateUserRequest) returns (User);
}

message GetUserRequest {
  int32 id = 1;
}

message CreateUserRequest {
  string name = 1;
  string email = 2;
}

message User {
  int32 id = 1;
  string name = 2;
  string email = 3;
  string created_at = 4;
}

Proto3 Key Concepts:

  • messages: Data structures (like structs/classes)
  • fields: Each has a type, name, and unique number
  • optional: Field may or may not be present
  • repeated: Array/list of values
  • service: Defines RPC methods (input -> output)

Step 2: Generate Code

Code Generation Commands

Generate protobuf code for each language:

Python:

protoc --python_out=. user_service.proto

TypeScript:

# Install protobufjs CLI
npm install -g protobufjs-cli

# Generate TypeScript definitions
pbjs -t static-module -w commonjs -o user_service.js user_service.proto
pbts -o user_service.d.ts user_service.js

Ruby:

grpc_tools_ruby_protoc --ruby_out=. user_service.proto

PHP:

protoc --php_out=. user_service.proto

Rust (add to build.rs):

fn main() {
    prost_build::compile_protos(&["user_service.proto"], &["."]).unwrap();
}

Step 3: Implement a Handler

Python gRPC Handler

Complete Python handler implementation for UserService with GetUser and CreateUser methods.

from spikard.grpc import GrpcHandler, GrpcRequest, GrpcResponse
import userservice_pb2  # Generated from proto
from datetime import datetime


class UserServiceHandler(GrpcHandler):
    """UserService gRPC handler implementation."""

    def __init__(self, user_repository):
        """Initialize handler with dependencies."""
        self.user_repository = user_repository

    async def handle_request(self, request: GrpcRequest) -> GrpcResponse:
        """
        Handle incoming gRPC requests.

        Routes to appropriate method based on request.method_name.
        """
        if request.method_name == "GetUser":
            return await self._get_user(request)
        elif request.method_name == "CreateUser":
            return await self._create_user(request)
        else:
            raise NotImplementedError(f"Unknown method: {request.method_name}")

    async def _get_user(self, request: GrpcRequest) -> GrpcResponse:
        """Handle GetUser RPC."""
        # 1. Deserialize request
        req = userservice_pb2.GetUserRequest()
        req.ParseFromString(request.payload)

        # 2. Validate input
        if req.id <= 0:
            raise ValueError("User ID must be positive")

        # 3. Business logic
        user = await self.user_repository.find_by_id(req.id)
        if not user:
            raise ValueError(f"User {req.id} not found")

        # 4. Build response
        response_user = userservice_pb2.User()
        response_user.id = user.id
        response_user.name = user.name
        response_user.email = user.email
        response_user.created_at = user.created_at.isoformat()

        # 5. Serialize and return
        return GrpcResponse(
            payload=response_user.SerializeToString(),
            metadata={"x-user-found": "true"}
        )

    async def _create_user(self, request: GrpcRequest) -> GrpcResponse:
        """Handle CreateUser RPC."""
        # 1. Deserialize request
        req = userservice_pb2.CreateUserRequest()
        req.ParseFromString(request.payload)

        # 2. Validate input
        if not req.name or not req.email:
            raise ValueError("Name and email are required")

        # 3. Check authorization from metadata
        auth_token = request.get_metadata("authorization")
        if not auth_token:
            raise PermissionError("Authentication required")

        # 4. Business logic
        user = await self.user_repository.create(
            name=req.name,
            email=req.email
        )

        # 5. Build response
        response_user = userservice_pb2.User()
        response_user.id = user.id
        response_user.name = user.name
        response_user.email = user.email
        response_user.created_at = datetime.utcnow().isoformat()

        # 6. Serialize with metadata
        return GrpcResponse(
            payload=response_user.SerializeToString(),
            metadata={
                "x-user-id": str(user.id),
                "x-created": "true"
            }
        )

Key Patterns

  • Async/await: All handlers are async for non-blocking I/O
  • ParseFromString(): Deserializes binary protobuf to Python object
  • SerializeToString(): Serializes Python object to binary protobuf
  • Exception mapping: ValueError -> INVALID_ARGUMENT, PermissionError -> PERMISSION_DENIED
  • Metadata access: request.get_metadata(key) returns str | None

Registration

from spikard import create_app
from spikard.grpc import GrpcService

# Create app
app = create_app()

# Create service registry
grpc_service = GrpcService()

# Register handler
handler = UserServiceHandler(user_repository=UserRepository())
grpc_service.register_handler("userservice.UserService", handler)

# App is now ready to serve gRPC requests

TypeScript gRPC Handler

Complete TypeScript handler implementation for UserService with GetUser and CreateUser methods.

import {
  GrpcHandler,
  GrpcRequest,
  GrpcResponse,
  GrpcError,
  GrpcStatusCode,
  createServiceHandler,
  createUnaryHandler,
} from 'spikard';
import * as userservice from './userservice_pb';  // Generated protobufjs types

class UserServiceHandler implements GrpcHandler {
  constructor(private userRepository: UserRepository) {}

  async handleRequest(request: GrpcRequest): Promise<GrpcResponse> {
    /**
     * Handle incoming gRPC requests.
     * Routes to appropriate method based on request.methodName.
     */
    switch (request.methodName) {
      case 'GetUser':
        return this.getUser(request);
      case 'CreateUser':
        return this.createUser(request);
      default:
        throw new GrpcError(
          GrpcStatusCode.UNIMPLEMENTED,
          `Method ${request.methodName} not implemented`
        );
    }
  }

  private async getUser(request: GrpcRequest): Promise<GrpcResponse> {
    // 1. Deserialize request
    const req = userservice.GetUserRequest.decode(request.payload);

    // 2. Validate input
    if (req.id <= 0) {
      throw new GrpcError(
        GrpcStatusCode.INVALID_ARGUMENT,
        'User ID must be positive'
      );
    }

    // 3. Business logic
    const user = await this.userRepository.findById(req.id);
    if (!user) {
      throw new GrpcError(
        GrpcStatusCode.NOT_FOUND,
        `User ${req.id} not found`
      );
    }

    // 4. Build response
    const responseUser = userservice.User.create({
      id: user.id,
      name: user.name,
      email: user.email,
      createdAt: user.createdAt.toISOString(),
    });

    // 5. Serialize and return
    const encoded = userservice.User.encode(responseUser).finish();
    return {
      payload: Buffer.from(encoded),
      metadata: { 'x-user-found': 'true' },
    };
  }

  private async createUser(request: GrpcRequest): Promise<GrpcResponse> {
    // 1. Deserialize request
    const req = userservice.CreateUserRequest.decode(request.payload);

    // 2. Validate input
    if (!req.name || !req.email) {
      throw new GrpcError(
        GrpcStatusCode.INVALID_ARGUMENT,
        'Name and email are required'
      );
    }

    // 3. Check authorization from metadata
    const authToken = request.metadata['authorization'];
    if (!authToken) {
      throw new GrpcError(
        GrpcStatusCode.UNAUTHENTICATED,
        'Authentication required'
      );
    }

    // 4. Business logic
    const user = await this.userRepository.create({
      name: req.name,
      email: req.email,
    });

    // 5. Build response
    const responseUser = userservice.User.create({
      id: user.id,
      name: user.name,
      email: user.email,
      createdAt: new Date().toISOString(),
    });

    // 6. Serialize with metadata
    const encoded = userservice.User.encode(responseUser).finish();
    return {
      payload: Buffer.from(encoded),
      metadata: {
        'x-user-id': user.id.toString(),
        'x-created': 'true',
      },
    };
  }
}

Key Patterns

  • Protobufjs: Uses .decode() and .encode().finish() for serialization
  • Buffer: gRPC payloads are Node.js Buffer objects
  • GrpcError: Throw with explicit status codes for proper error responses
  • Helper functions: createUnaryHandler and createServiceHandler reduce boilerplate
  • Type safety: Full TypeScript type inference for protobuf messages

Registration

import { GrpcService, Spikard } from 'spikard';

const grpcService = new GrpcService();

grpcService.registerUnary('userservice.UserService', 'GetUser', userServiceHandler);

const app = new Spikard();
app.useGrpc(grpcService);

Ruby gRPC Handler

Complete Ruby handler implementation for UserService with GetUser and CreateUser methods.

require 'spikard/grpc'
require 'userservice_pb'  # Generated from proto

class UserServiceHandler < Spikard::Grpc::Handler
  def initialize(user_repository)
    @user_repository = user_repository
  end

  def handle_request(request)
    # Route based on method name
    case request.method_name
    when 'GetUser'
      get_user(request)
    when 'CreateUser'
      create_user(request)
    else
      raise "Unknown method: #{request.method_name}"
    end
  end

  private

  def get_user(request)
    # 1. Deserialize request
    req = Userservice::GetUserRequest.decode(request.payload)

    # 2. Validate input
    raise ArgumentError, 'User ID must be positive' if req.id <= 0

    # 3. Business logic
    user = @user_repository.find_by_id(req.id)
    raise ArgumentError, "User #{req.id} not found" unless user

    # 4. Build response
    response_user = Userservice::User.new(
      id: user.id,
      name: user.name,
      email: user.email,
      created_at: user.created_at.iso8601
    )

    # 5. Serialize and return
    response = Spikard::Grpc::Response.new(
      payload: Userservice::User.encode(response_user)
    )
    response.metadata = { 'x-user-found' => 'true' }
    response
  end

  def create_user(request)
    # 1. Deserialize request
    req = Userservice::CreateUserRequest.decode(request.payload)

    # 2. Validate input
    if req.name.empty? || req.email.empty?
      raise ArgumentError, 'Name and email are required'
    end

    # 3. Check authorization from metadata
    auth_token = request.get_metadata('authorization')
    unless auth_token
      raise SecurityError, 'Authentication required'
    end

    # 4. Business logic
    user = @user_repository.create(
      name: req.name,
      email: req.email
    )

    # 5. Build response
    response_user = Userservice::User.new(
      id: user.id,
      name: user.name,
      email: user.email,
      created_at: Time.now.utc.iso8601
    )

    # 6. Serialize with metadata
    response = Spikard::Grpc::Response.new(
      payload: Userservice::User.encode(response_user)
    )
    response.metadata = {
      'x-user-id' => user.id.to_s,
      'x-created' => 'true'
    }
    response
  end
end

Key Patterns

  • Synchronous: Ruby handlers are synchronous (Rust runtime handles async)
  • .decode() / .encode(): Ruby protobuf methods for serialization
  • Metadata access: request.get_metadata(key) returns String | nil
  • Response construction: Create response, then set metadata separately
  • Error responses: Use Response.error() for error cases
  • Exception mapping: Rescue exceptions and convert to gRPC status codes

Error Handling

class UserServiceHandler < Spikard::Grpc::Handler
  def handle_request(request)
    case request.method_name
    when 'GetUser'
      get_user(request)
    else
      # Return error response
      Spikard::Grpc::Response.error(
        "Method not implemented: #{request.method_name}"
      )
    end
  rescue ArgumentError => e
    # Invalid argument error
    Spikard::Grpc::Response.error(e.message, 'INVALID_ARGUMENT')
  rescue SecurityError => e
    # Authentication error
    Spikard::Grpc::Response.error(e.message, 'UNAUTHENTICATED')
  rescue StandardError => e
    # Internal error
    Spikard::Grpc::Response.error("Internal error: #{e.message}")
  end
end

Registration

require 'spikard'

# Create service registry
service = Spikard::Grpc::Service.new

# Register handler
handler = UserServiceHandler.new(UserRepository.new)
service.register_handler('userservice.UserService', handler)

# Service ready to handle requests

PHP gRPC Handler

Complete PHP handler implementation for UserService with GetUser and CreateUser methods.

<?php

declare(strict_types=1);

use Spikard\Grpc\HandlerInterface;
use Spikard\Grpc\Request;
use Spikard\Grpc\Response;
use Userservice\GetUserRequest;
use Userservice\CreateUserRequest;
use Userservice\User;

class UserServiceHandler implements HandlerInterface
{
    public function __construct(
        private UserRepository $userRepository,
    ) {}

    public function handleRequest(Request $request): Response
    {
        // Route based on method name
        return match ($request->methodName) {
            'GetUser' => $this->getUser($request),
            'CreateUser' => $this->createUser($request),
            default => Response::error("Unknown method: {$request->methodName}"),
        };
    }

    private function getUser(Request $request): Response
    {
        try {
            // 1. Deserialize request
            $req = new GetUserRequest();
            $req->mergeFromString($request->payload);

            // 2. Validate input
            if ($req->getId() <= 0) {
                return Response::error('User ID must be positive');
            }

            // 3. Business logic
            $user = $this->userRepository->findById($req->getId());
            if (!$user) {
                return Response::error("User {$req->getId()} not found");
            }

            // 4. Build response
            $responseUser = new User();
            $responseUser->setId($user->getId());
            $responseUser->setName($user->getName());
            $responseUser->setEmail($user->getEmail());
            $responseUser->setCreatedAt($user->getCreatedAt()->format('c'));

            // 5. Serialize and return
            return new Response(
                payload: $responseUser->serializeToString(),
                metadata: ['x-user-found' => 'true']
            );

        } catch (\Exception $e) {
            return Response::error("Error: {$e->getMessage()}");
        }
    }

    private function createUser(Request $request): Response
    {
        try {
            // 1. Deserialize request
            $req = new CreateUserRequest();
            $req->mergeFromString($request->payload);

            // 2. Validate input
            if (empty($req->getName()) || empty($req->getEmail())) {
                return Response::error('Name and email are required');
            }

            // 3. Check authorization from metadata
            $authToken = $request->getMetadata('authorization');
            if (!$authToken) {
                return Response::error(
                    'Authentication required',
                    'UNAUTHENTICATED'
                );
            }

            // 4. Business logic
            $user = $this->userRepository->create(
                name: $req->getName(),
                email: $req->getEmail()
            );

            // 5. Build response
            $responseUser = new User();
            $responseUser->setId($user->getId());
            $responseUser->setName($user->getName());
            $responseUser->setEmail($user->getEmail());
            $responseUser->setCreatedAt((new \DateTime())->format('c'));

            // 6. Serialize with metadata
            return new Response(
                payload: $responseUser->serializeToString(),
                metadata: [
                    'x-user-id' => (string)$user->getId(),
                    'x-created' => 'true',
                ]
            );

        } catch (\Exception $e) {
            return Response::error("Error: {$e->getMessage()}");
        }
    }
}

Key Patterns

  • Synchronous: PHP handlers are synchronous
  • mergeFromString(): Deserializes binary protobuf (use merge, not parse)
  • serializeToString(): Serializes protobuf to binary
  • Getters/Setters: PHP protobuf uses getter/setter methods
  • Error responses: Return Response::error() instead of throwing
  • Named arguments: PHP 8.0+ named arguments for clarity
  • Type hints: Leverage PHP type system for safety

Registration

<?php

declare(strict_types=1);

use Spikard\Grpc;

// Create service registry
$service = Grpc::createService();

// Register handler
$handler = new UserServiceHandler(
    userRepository: new UserRepository()
);

$service->registerHandler('userservice.UserService', $handler);

// Service ready to handle requests

Rust gRPC Handler

Complete Rust handler implementation for UserService with GetUser and CreateUser methods.

use bytes::Bytes;
use spikard_http::grpc::{GrpcHandler, GrpcHandlerResult, GrpcRequestData, GrpcResponseData};
use tonic::Status;
use std::sync::Arc;

// Generated protobuf types
mod userservice {
    include!("userservice.rs");  // Generated by prost
}

pub struct UserServiceHandler {
    user_repository: Arc<dyn UserRepository + Send + Sync>,
}

impl UserServiceHandler {
    pub fn new(user_repository: Arc<dyn UserRepository + Send + Sync>) -> Self {
        Self { user_repository }
    }

    async fn get_user(&self, request: GrpcRequestData) -> GrpcHandlerResult {
        // 1. Deserialize request
        use prost::Message;
        let req = userservice::GetUserRequest::decode(request.payload)
            .map_err(|e| Status::invalid_argument(format!("Invalid request: {}", e)))?;

        // 2. Validate input
        if req.id <= 0 {
            return Err(Status::invalid_argument("User ID must be positive"));
        }

        // 3. Business logic
        let user = self.user_repository.find_by_id(req.id).await
            .map_err(|e| Status::internal(format!("Database error: {}", e)))?
            .ok_or_else(|| Status::not_found(format!("User {} not found", req.id)))?;

        // 4. Build response
        let response_user = userservice::User {
            id: user.id,
            name: user.name.clone(),
            email: user.email.clone(),
            created_at: user.created_at.to_rfc3339(),
        };

        // 5. Serialize
        let mut buf = Vec::new();
        response_user.encode(&mut buf)
            .map_err(|e| Status::internal(format!("Encoding error: {}", e)))?;

        // 6. Add metadata
        let mut metadata = tonic::metadata::MetadataMap::new();
        metadata.insert("x-user-found", "true".parse().map_err(|_| Status::internal("failed to parse metadata value"))?);

        Ok(GrpcResponseData {
            payload: Bytes::from(buf),
            metadata,
        })
    }

    async fn create_user(&self, request: GrpcRequestData) -> GrpcHandlerResult {
        // 1. Deserialize request
        use prost::Message;
        let req = userservice::CreateUserRequest::decode(request.payload)
            .map_err(|e| Status::invalid_argument(format!("Invalid request: {}", e)))?;

        // 2. Validate input
        if req.name.is_empty() || req.email.is_empty() {
            return Err(Status::invalid_argument("Name and email are required"));
        }

        // 3. Check authorization from metadata
        let auth_token = request.metadata
            .get("authorization")
            .and_then(|v| v.to_str().ok());

        if auth_token.is_none() {
            return Err(Status::unauthenticated("Authentication required"));
        }

        // 4. Business logic
        let user = self.user_repository.create(&req.name, &req.email).await
            .map_err(|e| Status::internal(format!("Create failed: {}", e)))?;

        // 5. Build response
        let response_user = userservice::User {
            id: user.id,
            name: user.name.clone(),
            email: user.email.clone(),
            created_at: chrono::Utc::now().to_rfc3339(),
        };

        // 6. Serialize
        let mut buf = Vec::new();
        response_user.encode(&mut buf)
            .map_err(|e| Status::internal(format!("Encoding error: {}", e)))?;

        // 7. Add metadata
        let mut metadata = tonic::metadata::MetadataMap::new();
        metadata.insert("x-user-id", user.id.to_string().parse().map_err(|_| Status::internal("failed to parse metadata value"))?);
        metadata.insert("x-created", "true".parse().map_err(|_| Status::internal("failed to parse metadata value"))?);

        Ok(GrpcResponseData {
            payload: Bytes::from(buf),
            metadata,
        })
    }
}

impl GrpcHandler for UserServiceHandler {
    fn call(&self, request: GrpcRequestData) -> Pin<Box<dyn Future<Output = GrpcHandlerResult> + Send>> {
        Box::pin(async move {
            match request.method_name.as_str() {
                "GetUser" => self.get_user(request).await,
                "CreateUser" => self.create_user(request).await,
                _ => Err(Status::unimplemented(format!("Unknown method: {}", request.method_name))),
            }
        })
    }

    fn service_name(&self) -> &'static str {
        "userservice.UserService"
    }
}

Key Patterns

  • Async/await: Handlers return Pin<Box<dyn Future>>
  • prost: Uses .decode() and .encode() for protobuf
  • Error handling: Return tonic::Status directly
  • Zero-copy: Uses Bytes for efficient payload handling
  • Type safety: Full compile-time type checking
  • Arc: Shared ownership for thread-safe repository access

Registration

use spikard_http::grpc::{GrpcRegistry, RpcMode};
use std::sync::Arc;

// Create handler
let user_repository = Arc::new(UserRepositoryImpl::new());
let handler = Arc::new(UserServiceHandler::new(user_repository));

// Register with gRPC runtime
let mut registry = GrpcRegistry::new();
registry.register("userservice.UserService", handler, RpcMode::Unary);

// Runtime ready to serve

Step 4: Register the Handler

from spikard import create_app
from spikard.grpc import GrpcService

# Create app
app = create_app()

# Create service registry
grpc_service = GrpcService()

# Register handler
handler = UserServiceHandler(user_repository=UserRepository())
grpc_service.register_handler("userservice.UserService", handler)

# App is now ready to serve gRPC requests
import { GrpcService, Spikard } from 'spikard';

const grpcService = new GrpcService();

grpcService.registerUnary('userservice.UserService', 'GetUser', userServiceHandler);

const app = new Spikard();
app.useGrpc(grpcService);
require 'spikard'

# Create service registry
service = Spikard::Grpc::Service.new

# Register handler
handler = UserServiceHandler.new(UserRepository.new)
service.register_handler('userservice.UserService', handler)

# Service ready to handle requests
<?php declare(strict_types=1);

use Spikard\Grpc;

// Create service registry
$service = Grpc::createService();

// Register handler
$handler = new UserServiceHandler(
    userRepository: new UserRepository()
);

$service->registerHandler('userservice.UserService', $handler);

// Service ready to handle requests
use spikard_http::grpc::{GrpcRegistry, RpcMode};
use std::sync::Arc;

// Create handler
let user_repository = Arc::new(UserRepositoryImpl::new());
let handler = Arc::new(UserServiceHandler::new(user_repository));

// Register with gRPC runtime
let mut registry = GrpcRegistry::new();
registry.register("userservice.UserService", handler, RpcMode::Unary);

// Runtime ready to serve

Step 5: Test the Handler

Python gRPC Handler Tests

Comprehensive test examples for gRPC handlers using pytest.

# test_user_handler.py
import pytest
from user_handler import UserServiceHandler
from spikard import GrpcRequest
import user_service_pb2 as pb


@pytest.mark.asyncio
async def test_get_user_success():
    """Test getting an existing user."""
    handler = UserServiceHandler()

    # Create request
    req = pb.GetUserRequest(user_id=1)
    grpc_request = GrpcRequest(
        service_name="userservice.v1.UserService",
        method_name="GetUser",
        payload=req.SerializeToString(),
    )

    # Call handler
    response = await handler.handle_request(grpc_request)

    # Deserialize response
    user_response = pb.UserResponse()
    user_response.ParseFromString(response.payload)

    # Assertions
    assert user_response.success is True
    assert user_response.user.id == 1
    assert user_response.user.name == "Alice"
    assert user_response.user.email == "alice@example.com"


@pytest.mark.asyncio
async def test_get_user_not_found():
    """Test getting a non-existent user."""
    handler = UserServiceHandler()

    # Create request for non-existent user
    req = pb.GetUserRequest(user_id=999)
    grpc_request = GrpcRequest(
        service_name="userservice.v1.UserService",
        method_name="GetUser",
        payload=req.SerializeToString(),
    )

    # Call handler
    response = await handler.handle_request(grpc_request)

    # Deserialize response
    user_response = pb.UserResponse()
    user_response.ParseFromString(response.payload)

    # Assertions
    assert user_response.success is False
    assert "not found" in user_response.error_message


@pytest.mark.asyncio
async def test_create_user_success():
    """Test creating a new user."""
    handler = UserServiceHandler()

    # Create request
    req = pb.CreateUserRequest(
        name="Charlie",
        email="charlie@example.com",
        phone="555-1234",
        tags=["developer", "remote"],
    )
    grpc_request = GrpcRequest(
        service_name="userservice.v1.UserService",
        method_name="CreateUser",
        payload=req.SerializeToString(),
    )

    # Call handler
    response = await handler.handle_request(grpc_request)

    # Deserialize response
    user_response = pb.UserResponse()
    user_response.ParseFromString(response.payload)

    # Assertions
    assert user_response.success is True
    assert user_response.user.id == 3  # Next available ID
    assert user_response.user.name == "Charlie"
    assert user_response.user.email == "charlie@example.com"
    assert user_response.user.phone == "555-1234"
    assert list(user_response.user.tags) == ["developer", "remote"]


@pytest.mark.asyncio
async def test_create_user_validation_error():
    """Test creating a user with missing required fields."""
    handler = UserServiceHandler()

    # Create request with missing email
    req = pb.CreateUserRequest(name="Invalid")
    grpc_request = GrpcRequest(
        service_name="userservice.v1.UserService",
        method_name="CreateUser",
        payload=req.SerializeToString(),
    )

    # Call handler
    response = await handler.handle_request(grpc_request)

    # Deserialize response
    user_response = pb.UserResponse()
    user_response.ParseFromString(response.payload)

    # Assertions
    assert user_response.success is False
    assert "required" in user_response.error_message


@pytest.mark.asyncio
async def test_unknown_method():
    """Test calling an unknown method."""
    handler = UserServiceHandler()

    grpc_request = GrpcRequest(
        service_name="userservice.v1.UserService",
        method_name="DeleteUser",  # Not implemented
        payload=b"",
    )

    # Should raise NotImplementedError
    with pytest.raises(NotImplementedError, match="Unknown method"):
        await handler.handle_request(grpc_request)


# Run tests
if __name__ == "__main__":
    pytest.main([__file__, "-v"])

Test Patterns

Using Fixtures

import pytest
from user_handler import UserServiceHandler

@pytest.fixture
def handler():
    """Create handler with test data."""
    return UserServiceHandler()

@pytest.fixture
def sample_user():
    """Create sample user for tests."""
    return pb.User(
        id=1,
        name="Test User",
        email="test@example.com",
    )

@pytest.mark.asyncio
async def test_with_fixtures(handler, sample_user):
    # Test using fixtures
    pass

Testing Error Cases

@pytest.mark.asyncio
async def test_handles_malformed_payload():
    """Test handler with malformed protobuf."""
    handler = UserServiceHandler()

    # Create request with invalid protobuf
    grpc_request = GrpcRequest(
        service_name="userservice.v1.UserService",
        method_name="GetUser",
        payload=b"invalid protobuf data",
    )

    # Should handle deserialization error gracefully
    with pytest.raises(Exception):  # Or specific protobuf exception
        await handler.handle_request(grpc_request)

Running Tests

pytest test_user_handler.py -v

TypeScript gRPC Handler Tests

Comprehensive test examples for gRPC handlers using Vitest.

// user_handler.test.ts
import { describe, it, expect } from 'vitest';
import { GrpcRequest } from '@spikard/node';
import { UserServiceHandler } from './user_handler';
import { userservice } from './user_service';

describe('UserServiceHandler', () => {
  it('should get an existing user', async () => {
    const handler = new UserServiceHandler();

    // Create request
    const req = userservice.v1.GetUserRequest.create({ userId: 1 });
    const payload = userservice.v1.GetUserRequest.encode(req).finish();
    const grpcRequest = new GrpcRequest({
      serviceName: 'userservice.v1.UserService',
      methodName: 'GetUser',
      payload,
    });

    // Call handler
    const response = await handler.handleRequest(grpcRequest);

    // Deserialize response
    const userResponse = userservice.v1.UserResponse.decode(response.payload);

    // Assertions
    expect(userResponse.success).toBe(true);
    expect(userResponse.user?.id).toBe(1);
    expect(userResponse.user?.name).toBe('Alice');
  });

  it('should return error for non-existent user', async () => {
    const handler = new UserServiceHandler();

    const req = userservice.v1.GetUserRequest.create({ userId: 999 });
    const payload = userservice.v1.GetUserRequest.encode(req).finish();
    const grpcRequest = new GrpcRequest({
      serviceName: 'userservice.v1.UserService',
      methodName: 'GetUser',
      payload,
    });

    const response = await handler.handleRequest(grpcRequest);
    const userResponse = userservice.v1.UserResponse.decode(response.payload);

    expect(userResponse.success).toBe(false);
    expect(userResponse.errorMessage).toContain('not found');
  });

  it('should create a new user', async () => {
    const handler = new UserServiceHandler();

    const req = userservice.v1.CreateUserRequest.create({
      name: 'Charlie',
      email: 'charlie@example.com',
      tags: ['developer'],
    });
    const payload = userservice.v1.CreateUserRequest.encode(req).finish();
    const grpcRequest = new GrpcRequest({
      serviceName: 'userservice.v1.UserService',
      methodName: 'CreateUser',
      payload,
    });

    const response = await handler.handleRequest(grpcRequest);
    const userResponse = userservice.v1.UserResponse.decode(response.payload);

    expect(userResponse.success).toBe(true);
    expect(userResponse.user?.name).toBe('Charlie');
    expect(userResponse.user?.id).toBe(3);
  });

  it('should validate required fields on create', async () => {
    const handler = new UserServiceHandler();

    const req = userservice.v1.CreateUserRequest.create({
      name: 'Test User',
      email: '', // Missing email
    });
    const payload = userservice.v1.CreateUserRequest.encode(req).finish();
    const grpcRequest = new GrpcRequest({
      serviceName: 'userservice.v1.UserService',
      methodName: 'CreateUser',
      payload,
    });

    const response = await handler.handleRequest(grpcRequest);
    const userResponse = userservice.v1.UserResponse.decode(response.payload);

    expect(userResponse.success).toBe(false);
    expect(userResponse.errorMessage).toContain('required');
  });

  it('should throw for unknown methods', async () => {
    const handler = new UserServiceHandler();

    const grpcRequest = new GrpcRequest({
      serviceName: 'userservice.v1.UserService',
      methodName: 'DeleteUser', // Not implemented
      payload: new Uint8Array(),
    });

    await expect(handler.handleRequest(grpcRequest)).rejects.toThrow(
      'Unknown method'
    );
  });
});

Test Patterns

Using Test Helpers

// test-helpers.ts
import { GrpcRequest } from '@spikard/node';
import { userservice } from './user_service';

export function createGetUserRequest(userId: number): GrpcRequest {
  const req = userservice.v1.GetUserRequest.create({ userId });
  const payload = userservice.v1.GetUserRequest.encode(req).finish();
  return new GrpcRequest({
    serviceName: 'userservice.v1.UserService',
    methodName: 'GetUser',
    payload,
  });
}

export function createCreateUserRequest(
  name: string,
  email: string,
  tags?: string[]
): GrpcRequest {
  const req = userservice.v1.CreateUserRequest.create({
    name,
    email,
    tags: tags || [],
  });
  const payload = userservice.v1.CreateUserRequest.encode(req).finish();
  return new GrpcRequest({
    serviceName: 'userservice.v1.UserService',
    methodName: 'CreateUser',
    payload,
  });
}

Testing with Metadata

it('should handle authorization header', async () => {
  const handler = new UserServiceHandler();

  const req = userservice.v1.CreateUserRequest.create({
    name: 'Test',
    email: 'test@example.com',
  });
  const payload = userservice.v1.CreateUserRequest.encode(req).finish();
  const grpcRequest = new GrpcRequest({
    serviceName: 'userservice.v1.UserService',
    methodName: 'CreateUser',
    payload,
    metadata: {
      authorization: 'Bearer valid-token',
    },
  });

  const response = await handler.handleRequest(grpcRequest);
  const userResponse = userservice.v1.UserResponse.decode(response.payload);

  expect(userResponse.success).toBe(true);
});

Running Tests

# Run all tests
npx vitest

# Run with coverage
npx vitest --coverage

# Run specific file
npx vitest user_handler.test.ts

Ruby gRPC Handler Tests

Comprehensive test examples for gRPC handlers using RSpec.

# spec/user_service_handler_spec.rb
require 'spec_helper'
require 'spikard/grpc'
require 'userservice_pb'
require_relative '../lib/user_service_handler'

RSpec.describe UserServiceHandler do
  let(:user_repository) { instance_double('UserRepository') }
  let(:handler) { described_class.new(user_repository) }

  describe '#handle_request' do
    context 'GetUser' do
      it 'returns an existing user successfully' do
        # Setup mock data
        mock_user = OpenStruct.new(
          id: 1,
          name: 'Alice',
          email: 'alice@example.com',
          created_at: Time.now.utc
        )
        allow(user_repository).to receive(:find_by_id).with(1).and_return(mock_user)

        # Create request
        req = Userservice::GetUserRequest.new(id: 1)
        grpc_request = Spikard::Grpc::Request.new(
          service_name: 'userservice.v1.UserService',
          method_name: 'GetUser',
          payload: Userservice::GetUserRequest.encode(req)
        )

        # Call handler
        response = handler.handle_request(grpc_request)

        # Deserialize response
        user_response = Userservice::User.decode(response.payload)

        # Assertions
        expect(user_response.id).to eq(1)
        expect(user_response.name).to eq('Alice')
        expect(user_response.email).to eq('alice@example.com')
        expect(response.metadata['x-user-found']).to eq('true')
      end

      it 'returns error for non-existent user' do
        allow(user_repository).to receive(:find_by_id).with(999).and_return(nil)

        # Create request for non-existent user
        req = Userservice::GetUserRequest.new(id: 999)
        grpc_request = Spikard::Grpc::Request.new(
          service_name: 'userservice.v1.UserService',
          method_name: 'GetUser',
          payload: Userservice::GetUserRequest.encode(req)
        )

        # Call handler - should raise error
        expect { handler.handle_request(grpc_request) }.to raise_error(
          ArgumentError, /not found/
        )
      end

      it 'validates user ID is positive' do
        req = Userservice::GetUserRequest.new(id: 0)
        grpc_request = Spikard::Grpc::Request.new(
          service_name: 'userservice.v1.UserService',
          method_name: 'GetUser',
          payload: Userservice::GetUserRequest.encode(req)
        )

        expect { handler.handle_request(grpc_request) }.to raise_error(
          ArgumentError, /must be positive/
        )
      end
    end

    context 'CreateUser' do
      it 'creates a new user successfully' do
        mock_user = OpenStruct.new(
          id: 3,
          name: 'Charlie',
          email: 'charlie@example.com'
        )
        allow(user_repository).to receive(:create).and_return(mock_user)

        # Create request with authorization metadata
        req = Userservice::CreateUserRequest.new(
          name: 'Charlie',
          email: 'charlie@example.com'
        )
        grpc_request = Spikard::Grpc::Request.new(
          service_name: 'userservice.v1.UserService',
          method_name: 'CreateUser',
          payload: Userservice::CreateUserRequest.encode(req),
          metadata: { 'authorization' => 'Bearer valid-token' }
        )

        # Call handler
        response = handler.handle_request(grpc_request)

        # Deserialize response
        user_response = Userservice::User.decode(response.payload)

        # Assertions
        expect(user_response.id).to eq(3)
        expect(user_response.name).to eq('Charlie')
        expect(user_response.email).to eq('charlie@example.com')
        expect(response.metadata['x-user-id']).to eq('3')
        expect(response.metadata['x-created']).to eq('true')
      end

      it 'returns error when name is missing' do
        req = Userservice::CreateUserRequest.new(
          name: '',
          email: 'test@example.com'
        )
        grpc_request = Spikard::Grpc::Request.new(
          service_name: 'userservice.v1.UserService',
          method_name: 'CreateUser',
          payload: Userservice::CreateUserRequest.encode(req),
          metadata: { 'authorization' => 'Bearer token' }
        )

        expect { handler.handle_request(grpc_request) }.to raise_error(
          ArgumentError, /required/
        )
      end

      it 'returns error when authorization is missing' do
        req = Userservice::CreateUserRequest.new(
          name: 'Test',
          email: 'test@example.com'
        )
        grpc_request = Spikard::Grpc::Request.new(
          service_name: 'userservice.v1.UserService',
          method_name: 'CreateUser',
          payload: Userservice::CreateUserRequest.encode(req)
        )

        expect { handler.handle_request(grpc_request) }.to raise_error(
          SecurityError, /Authentication required/
        )
      end
    end

    context 'unknown method' do
      it 'raises error for unknown method' do
        grpc_request = Spikard::Grpc::Request.new(
          service_name: 'userservice.v1.UserService',
          method_name: 'DeleteUser',
          payload: ''
        )

        expect { handler.handle_request(grpc_request) }.to raise_error(
          RuntimeError, /Unknown method/
        )
      end
    end
  end
end

Test Patterns

Using Shared Examples

RSpec.shared_examples 'authenticated endpoint' do |method_name|
  it 'requires authentication' do
    grpc_request = Spikard::Grpc::Request.new(
      service_name: 'userservice.v1.UserService',
      method_name: method_name,
      payload: request_payload
    )

    expect { handler.handle_request(grpc_request) }.to raise_error(
      SecurityError, /Authentication required/
    )
  end
end

RSpec.describe UserServiceHandler do
  describe 'CreateUser' do
    let(:request_payload) do
      req = Userservice::CreateUserRequest.new(name: 'Test', email: 'test@example.com')
      Userservice::CreateUserRequest.encode(req)
    end

    include_examples 'authenticated endpoint', 'CreateUser'
  end
end

Testing Error Responses

RSpec.describe UserServiceHandler do
  describe 'error handling' do
    it 'handles malformed protobuf gracefully' do
      grpc_request = Spikard::Grpc::Request.new(
        service_name: 'userservice.v1.UserService',
        method_name: 'GetUser',
        payload: 'invalid protobuf data'
      )

      expect { handler.handle_request(grpc_request) }.to raise_error(
        Google::Protobuf::ParseError
      )
    end
  end
end

Using let! for Setup

RSpec.describe UserServiceHandler do
  let!(:alice) do
    OpenStruct.new(id: 1, name: 'Alice', email: 'alice@example.com', created_at: Time.now)
  end
  let!(:bob) do
    OpenStruct.new(id: 2, name: 'Bob', email: 'bob@example.com', created_at: Time.now)
  end

  before do
    allow(user_repository).to receive(:find_by_id).with(1).and_return(alice)
    allow(user_repository).to receive(:find_by_id).with(2).and_return(bob)
  end

  it 'retrieves different users' do
    # Test with alice
    req = Userservice::GetUserRequest.new(id: 1)
    grpc_request = Spikard::Grpc::Request.new(
      service_name: 'userservice.v1.UserService',
      method_name: 'GetUser',
      payload: Userservice::GetUserRequest.encode(req)
    )

    response = handler.handle_request(grpc_request)
    user = Userservice::User.decode(response.payload)

    expect(user.name).to eq('Alice')
  end
end

Running Tests

# Run all tests
bundle exec rspec

# Run with verbose output
bundle exec rspec --format documentation

# Run specific file
bundle exec rspec spec/user_service_handler_spec.rb

# Run specific example
bundle exec rspec spec/user_service_handler_spec.rb:15

PHP gRPC Handler Tests

Comprehensive test examples for gRPC handlers using PHPUnit.

<?php
// tests/UserServiceHandlerTest.php

declare(strict_types=1);

namespace Tests;

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Spikard\Grpc\Request;
use Spikard\Grpc\Response;
use Userservice\GetUserRequest;
use Userservice\CreateUserRequest;
use Userservice\User;

class UserServiceHandlerTest extends TestCase
{
    private UserServiceHandler $handler;
    private MockObject $userRepository;

    protected function setUp(): void
    {
        $this->userRepository = $this->createMock(UserRepository::class);
        $this->handler = new UserServiceHandler($this->userRepository);
    }

    public function testGetUserSuccess(): void
    {
        // Setup mock data
        $mockUser = new \stdClass();
        $mockUser->id = 1;
        $mockUser->name = 'Alice';
        $mockUser->email = 'alice@example.com';
        $mockUser->createdAt = new \DateTime();

        $this->userRepository
            ->expects($this->once())
            ->method('findById')
            ->with(1)
            ->willReturn($mockUser);

        // Create request
        $req = new GetUserRequest();
        $req->setId(1);

        $grpcRequest = new Request(
            serviceName: 'userservice.v1.UserService',
            methodName: 'GetUser',
            payload: $req->serializeToString()
        );

        // Call handler
        $response = $this->handler->handleRequest($grpcRequest);

        // Deserialize response
        $userResponse = new User();
        $userResponse->mergeFromString($response->payload);

        // Assertions
        $this->assertEquals(1, $userResponse->getId());
        $this->assertEquals('Alice', $userResponse->getName());
        $this->assertEquals('alice@example.com', $userResponse->getEmail());
        $this->assertEquals('true', $response->getMetadata('x-user-found'));
    }

    public function testGetUserNotFound(): void
    {
        $this->userRepository
            ->expects($this->once())
            ->method('findById')
            ->with(999)
            ->willReturn(null);

        // Create request for non-existent user
        $req = new GetUserRequest();
        $req->setId(999);

        $grpcRequest = new Request(
            serviceName: 'userservice.v1.UserService',
            methodName: 'GetUser',
            payload: $req->serializeToString()
        );

        // Call handler
        $response = $this->handler->handleRequest($grpcRequest);

        // Should return error response
        $this->assertTrue($response->isError());
        $this->assertStringContainsString('not found', $response->errorMessage);
    }

    public function testGetUserInvalidId(): void
    {
        $req = new GetUserRequest();
        $req->setId(0);

        $grpcRequest = new Request(
            serviceName: 'userservice.v1.UserService',
            methodName: 'GetUser',
            payload: $req->serializeToString()
        );

        $response = $this->handler->handleRequest($grpcRequest);

        $this->assertTrue($response->isError());
        $this->assertStringContainsString('must be positive', $response->errorMessage);
    }

    public function testCreateUserSuccess(): void
    {
        $mockUser = new \stdClass();
        $mockUser->id = 3;
        $mockUser->name = 'Charlie';
        $mockUser->email = 'charlie@example.com';

        $this->userRepository
            ->expects($this->once())
            ->method('create')
            ->willReturn($mockUser);

        // Create request
        $req = new CreateUserRequest();
        $req->setName('Charlie');
        $req->setEmail('charlie@example.com');

        $grpcRequest = new Request(
            serviceName: 'userservice.v1.UserService',
            methodName: 'CreateUser',
            payload: $req->serializeToString(),
            metadata: ['authorization' => 'Bearer valid-token']
        );

        // Call handler
        $response = $this->handler->handleRequest($grpcRequest);

        // Deserialize response
        $userResponse = new User();
        $userResponse->mergeFromString($response->payload);

        // Assertions
        $this->assertEquals(3, $userResponse->getId());
        $this->assertEquals('Charlie', $userResponse->getName());
        $this->assertEquals('charlie@example.com', $userResponse->getEmail());
        $this->assertEquals('3', $response->getMetadata('x-user-id'));
        $this->assertEquals('true', $response->getMetadata('x-created'));
    }

    public function testCreateUserValidationError(): void
    {
        // Create request with missing email
        $req = new CreateUserRequest();
        $req->setName('Test User');
        $req->setEmail('');

        $grpcRequest = new Request(
            serviceName: 'userservice.v1.UserService',
            methodName: 'CreateUser',
            payload: $req->serializeToString(),
            metadata: ['authorization' => 'Bearer token']
        );

        $response = $this->handler->handleRequest($grpcRequest);

        $this->assertTrue($response->isError());
        $this->assertStringContainsString('required', $response->errorMessage);
    }

    public function testCreateUserRequiresAuthentication(): void
    {
        $req = new CreateUserRequest();
        $req->setName('Test');
        $req->setEmail('test@example.com');

        // Request without authorization header
        $grpcRequest = new Request(
            serviceName: 'userservice.v1.UserService',
            methodName: 'CreateUser',
            payload: $req->serializeToString()
        );

        $response = $this->handler->handleRequest($grpcRequest);

        $this->assertTrue($response->isError());
        $this->assertStringContainsString('Authentication required', $response->errorMessage);
        $this->assertEquals('16', $response->getMetadata('grpc-status'));
    }

    public function testUnknownMethod(): void
    {
        $grpcRequest = new Request(
            serviceName: 'userservice.v1.UserService',
            methodName: 'DeleteUser',
            payload: ''
        );

        $response = $this->handler->handleRequest($grpcRequest);

        $this->assertTrue($response->isError());
        $this->assertStringContainsString('Unknown method', $response->errorMessage);
    }
}

Test Patterns

Using Data Providers

<?php

class UserServiceHandlerTest extends TestCase
{
    /**
     * @dataProvider invalidUserIdProvider
     */
    public function testGetUserWithInvalidIds(int $invalidId, string $expectedError): void
    {
        $req = new GetUserRequest();
        $req->setId($invalidId);

        $grpcRequest = new Request(
            serviceName: 'userservice.v1.UserService',
            methodName: 'GetUser',
            payload: $req->serializeToString()
        );

        $response = $this->handler->handleRequest($grpcRequest);

        $this->assertTrue($response->isError());
        $this->assertStringContainsString($expectedError, $response->errorMessage);
    }

    public static function invalidUserIdProvider(): array
    {
        return [
            'zero id' => [0, 'must be positive'],
            'negative id' => [-1, 'must be positive'],
        ];
    }
}

Testing Error Handling

<?php

class UserServiceHandlerTest extends TestCase
{
    public function testHandlesMalformedPayload(): void
    {
        $grpcRequest = new Request(
            serviceName: 'userservice.v1.UserService',
            methodName: 'GetUser',
            payload: 'invalid protobuf data'
        );

        $response = $this->handler->handleRequest($grpcRequest);

        // Should return error, not throw exception
        $this->assertTrue($response->isError());
        $this->assertStringContainsString('Error', $response->errorMessage);
    }

    public function testHandlesRepositoryException(): void
    {
        $this->userRepository
            ->method('findById')
            ->willThrowException(new \RuntimeException('Database connection failed'));

        $req = new GetUserRequest();
        $req->setId(1);

        $grpcRequest = new Request(
            serviceName: 'userservice.v1.UserService',
            methodName: 'GetUser',
            payload: $req->serializeToString()
        );

        $response = $this->handler->handleRequest($grpcRequest);

        $this->assertTrue($response->isError());
        $this->assertStringContainsString('Database connection failed', $response->errorMessage);
    }
}

Testing with Metadata

<?php

class UserServiceHandlerTest extends TestCase
{
    public function testHandlerReadsCustomMetadata(): void
    {
        $mockUser = new \stdClass();
        $mockUser->id = 1;
        $mockUser->name = 'Alice';
        $mockUser->email = 'alice@example.com';
        $mockUser->createdAt = new \DateTime();

        $this->userRepository->method('findById')->willReturn($mockUser);

        $req = new GetUserRequest();
        $req->setId(1);

        $grpcRequest = new Request(
            serviceName: 'userservice.v1.UserService',
            methodName: 'GetUser',
            payload: $req->serializeToString(),
            metadata: [
                'x-request-id' => 'abc-123',
                'x-trace-id' => 'trace-456',
            ]
        );

        $response = $this->handler->handleRequest($grpcRequest);

        $this->assertFalse($response->isError());
    }
}

Running Tests

# Run all tests
./vendor/bin/phpunit

# Run with verbose output
./vendor/bin/phpunit --verbose

# Run specific test file
./vendor/bin/phpunit tests/UserServiceHandlerTest.php

# Run specific test method
./vendor/bin/phpunit --filter testGetUserSuccess

# Run with coverage
./vendor/bin/phpunit --coverage-html coverage/

Rust gRPC Handler Tests

Comprehensive test examples for gRPC handlers using Tokio test.

// tests/user_handler_test.rs
use bytes::Bytes;
use spikard_http::grpc::{GrpcHandler, GrpcRequestData, GrpcResponseData};
use std::sync::Arc;
use tonic::metadata::MetadataMap;

mod userservice {
    include!("../src/userservice.rs");
}

use crate::user_handler::UserServiceHandler;

// Mock repository for testing
struct MockUserRepository {
    users: std::sync::RwLock<Vec<userservice::User>>,
}

impl MockUserRepository {
    fn new() -> Self {
        let users = vec![
            userservice::User {
                id: 1,
                name: "Alice".to_string(),
                email: "alice@example.com".to_string(),
                created_at: "2024-01-01T00:00:00Z".to_string(),
            },
            userservice::User {
                id: 2,
                name: "Bob".to_string(),
                email: "bob@example.com".to_string(),
                created_at: "2024-01-02T00:00:00Z".to_string(),
            },
        ];
        Self {
            users: std::sync::RwLock::new(users),
        }
    }
}

#[async_trait::async_trait]
impl UserRepository for MockUserRepository {
    async fn find_by_id(&self, id: i32) -> Result<Option<userservice::User>, String> {
        let users = self.users.read().expect("lock poisoned");
        Ok(users.iter().find(|u| u.id == id).cloned())
    }

    async fn create(&self, name: &str, email: &str) -> Result<userservice::User, String> {
        let mut users = self.users.write().expect("lock poisoned");
        let new_id = users.len() as i32 + 1;
        let user = userservice::User {
            id: new_id,
            name: name.to_string(),
            email: email.to_string(),
            created_at: chrono::Utc::now().to_rfc3339(),
        };
        users.push(user.clone());
        Ok(user)
    }
}

fn create_handler() -> UserServiceHandler {
    let repo = Arc::new(MockUserRepository::new());
    UserServiceHandler::new(repo)
}

#[tokio::test]
async fn test_get_user_success() {
    use prost::Message;

    let handler = create_handler();

    // Create request
    let req = userservice::GetUserRequest { id: 1 };
    let mut buf = Vec::new();
    req.encode(&mut buf).expect("failed to encode protobuf request");

    let grpc_request = GrpcRequestData {
        service_name: "userservice.v1.UserService".to_string(),
        method_name: "GetUser".to_string(),
        payload: Bytes::from(buf),
        metadata: MetadataMap::new(),
    };

    // Call handler
    let response = handler.call(grpc_request).await.expect("handler call failed");

    // Deserialize response
    let user_response = userservice::User::decode(response.payload).expect("failed to decode response payload");

    // Assertions
    assert_eq!(user_response.id, 1);
    assert_eq!(user_response.name, "Alice");
    assert_eq!(user_response.email, "alice@example.com");
    assert_eq!(
        response.metadata.get("x-user-found").expect("x-user-found header missing").to_str().expect("invalid metadata value"),
        "true"
    );
}

#[tokio::test]
async fn test_get_user_not_found() {
    use prost::Message;

    let handler = create_handler();

    // Create request for non-existent user
    let req = userservice::GetUserRequest { id: 999 };
    let mut buf = Vec::new();
    req.encode(&mut buf).expect("failed to encode protobuf request");

    let grpc_request = GrpcRequestData {
        service_name: "userservice.v1.UserService".to_string(),
        method_name: "GetUser".to_string(),
        payload: Bytes::from(buf),
        metadata: MetadataMap::new(),
    };

    // Call handler - should return error
    let result = handler.call(grpc_request).await;

    assert!(result.is_err());
    let status = result.unwrap_err();
    assert_eq!(status.code(), tonic::Code::NotFound);
    assert!(status.message().contains("not found"));
}

#[tokio::test]
async fn test_get_user_invalid_id() {
    use prost::Message;

    let handler = create_handler();

    // Create request with invalid ID
    let req = userservice::GetUserRequest { id: 0 };
    let mut buf = Vec::new();
    req.encode(&mut buf).expect("failed to encode protobuf request");

    let grpc_request = GrpcRequestData {
        service_name: "userservice.v1.UserService".to_string(),
        method_name: "GetUser".to_string(),
        payload: Bytes::from(buf),
        metadata: MetadataMap::new(),
    };

    let result = handler.call(grpc_request).await;

    assert!(result.is_err());
    let status = result.unwrap_err();
    assert_eq!(status.code(), tonic::Code::InvalidArgument);
    assert!(status.message().contains("must be positive"));
}

#[tokio::test]
async fn test_create_user_success() {
    use prost::Message;

    let handler = create_handler();

    // Create request
    let req = userservice::CreateUserRequest {
        name: "Charlie".to_string(),
        email: "charlie@example.com".to_string(),
    };
    let mut buf = Vec::new();
    req.encode(&mut buf).expect("failed to encode protobuf request");

    // Add authorization metadata
    let mut metadata = MetadataMap::new();
    metadata.insert("authorization", "Bearer valid-token".parse().expect("failed to parse authorization header"));

    let grpc_request = GrpcRequestData {
        service_name: "userservice.v1.UserService".to_string(),
        method_name: "CreateUser".to_string(),
        payload: Bytes::from(buf),
        metadata,
    };

    // Call handler
    let response = handler.call(grpc_request).await.expect("handler call failed");

    // Deserialize response
    let user_response = userservice::User::decode(response.payload).expect("failed to decode response payload");

    // Assertions
    assert_eq!(user_response.id, 3); // Next available ID
    assert_eq!(user_response.name, "Charlie");
    assert_eq!(user_response.email, "charlie@example.com");
    assert_eq!(
        response.metadata.get("x-user-id").expect("x-user-id header missing").to_str().expect("invalid metadata value"),
        "3"
    );
    assert_eq!(
        response.metadata.get("x-created").expect("x-created header missing").to_str().expect("invalid metadata value"),
        "true"
    );
}

#[tokio::test]
async fn test_create_user_validation_error() {
    use prost::Message;

    let handler = create_handler();

    // Create request with missing email
    let req = userservice::CreateUserRequest {
        name: "Test User".to_string(),
        email: "".to_string(),
    };
    let mut buf = Vec::new();
    req.encode(&mut buf).expect("failed to encode protobuf request");

    let mut metadata = MetadataMap::new();
    metadata.insert("authorization", "Bearer token".parse().expect("failed to parse authorization header"));

    let grpc_request = GrpcRequestData {
        service_name: "userservice.v1.UserService".to_string(),
        method_name: "CreateUser".to_string(),
        payload: Bytes::from(buf),
        metadata,
    };

    let result = handler.call(grpc_request).await;

    assert!(result.is_err());
    let status = result.unwrap_err();
    assert_eq!(status.code(), tonic::Code::InvalidArgument);
    assert!(status.message().contains("required"));
}

#[tokio::test]
async fn test_create_user_requires_authentication() {
    use prost::Message;

    let handler = create_handler();

    let req = userservice::CreateUserRequest {
        name: "Test".to_string(),
        email: "test@example.com".to_string(),
    };
    let mut buf = Vec::new();
    req.encode(&mut buf).expect("failed to encode protobuf request");

    // Request without authorization header
    let grpc_request = GrpcRequestData {
        service_name: "userservice.v1.UserService".to_string(),
        method_name: "CreateUser".to_string(),
        payload: Bytes::from(buf),
        metadata: MetadataMap::new(),
    };

    let result = handler.call(grpc_request).await;

    assert!(result.is_err());
    let status = result.unwrap_err();
    assert_eq!(status.code(), tonic::Code::Unauthenticated);
    assert!(status.message().contains("Authentication required"));
}

#[tokio::test]
async fn test_unknown_method() {
    let handler = create_handler();

    let grpc_request = GrpcRequestData {
        service_name: "userservice.v1.UserService".to_string(),
        method_name: "DeleteUser".to_string(),
        payload: Bytes::new(),
        metadata: MetadataMap::new(),
    };

    let result = handler.call(grpc_request).await;

    assert!(result.is_err());
    let status = result.unwrap_err();
    assert_eq!(status.code(), tonic::Code::Unimplemented);
    assert!(status.message().contains("Unknown method"));
}

Test Patterns

Using Test Fixtures

use once_cell::sync::Lazy;

static TEST_HANDLER: Lazy<UserServiceHandler> = Lazy::new(|| {
    let repo = Arc::new(MockUserRepository::new());
    UserServiceHandler::new(repo)
});

#[tokio::test]
async fn test_with_shared_handler() {
    use prost::Message;

    let req = userservice::GetUserRequest { id: 1 };
    let mut buf = Vec::new();
    req.encode(&mut buf).expect("failed to encode protobuf request");

    let grpc_request = GrpcRequestData {
        service_name: "userservice.v1.UserService".to_string(),
        method_name: "GetUser".to_string(),
        payload: Bytes::from(buf),
        metadata: MetadataMap::new(),
    };

    let response = TEST_HANDLER.call(grpc_request).await.expect("handler call failed");
    let user = userservice::User::decode(response.payload).expect("failed to decode response payload");

    assert_eq!(user.name, "Alice");
}

Testing Error Cases

#[tokio::test]
async fn test_handles_malformed_payload() {
    let handler = create_handler();

    let grpc_request = GrpcRequestData {
        service_name: "userservice.v1.UserService".to_string(),
        method_name: "GetUser".to_string(),
        payload: Bytes::from("invalid protobuf data"),
        metadata: MetadataMap::new(),
    };

    let result = handler.call(grpc_request).await;

    assert!(result.is_err());
    let status = result.unwrap_err();
    assert_eq!(status.code(), tonic::Code::InvalidArgument);
}

Helper Functions

fn create_get_user_request(user_id: i32) -> GrpcRequestData {
    use prost::Message;

    let req = userservice::GetUserRequest { id: user_id };
    let mut buf = Vec::new();
    req.encode(&mut buf).expect("failed to encode protobuf request");

    GrpcRequestData {
        service_name: "userservice.v1.UserService".to_string(),
        method_name: "GetUser".to_string(),
        payload: Bytes::from(buf),
        metadata: MetadataMap::new(),
    }
}

fn create_create_user_request(name: &str, email: &str, auth_token: Option<&str>) -> GrpcRequestData {
    use prost::Message;

    let req = userservice::CreateUserRequest {
        name: name.to_string(),
        email: email.to_string(),
    };
    let mut buf = Vec::new();
    req.encode(&mut buf).expect("failed to encode protobuf request");

    let mut metadata = MetadataMap::new();
    if let Some(token) = auth_token {
        metadata.insert("authorization", token.parse().expect("failed to parse authorization header"));
    }

    GrpcRequestData {
        service_name: "userservice.v1.UserService".to_string(),
        method_name: "CreateUser".to_string(),
        payload: Bytes::from(buf),
        metadata,
    }
}

#[tokio::test]
async fn test_with_helpers() {
    let handler = create_handler();

    let request = create_get_user_request(1);
    let response = handler.call(request).await.expect("handler call failed");

    let user = userservice::User::decode(response.payload).expect("failed to decode response payload");
    assert_eq!(user.id, 1);
}

Running Tests

# Run all tests
cargo test

# Run with output
cargo test -- --nocapture

# Run specific test
cargo test test_get_user_success

# Run tests matching pattern
cargo test test_get_user

# Run with coverage (requires cargo-tarpaulin)
cargo tarpaulin --out Html

Complete Handler Examples

Full-featured handler implementations showing routing, validation, business logic, and metadata:

from spikard.grpc import GrpcHandler, GrpcRequest, GrpcResponse
import userservice_pb2  # Generated from proto
from datetime import datetime


class UserServiceHandler(GrpcHandler):
    """UserService gRPC handler implementation."""

    def __init__(self, user_repository):
        """Initialize handler with dependencies."""
        self.user_repository = user_repository

    async def handle_request(self, request: GrpcRequest) -> GrpcResponse:
        """
        Handle incoming gRPC requests.

        Routes to appropriate method based on request.method_name.
        """
        if request.method_name == "GetUser":
            return await self._get_user(request)
        elif request.method_name == "CreateUser":
            return await self._create_user(request)
        else:
            raise NotImplementedError(f"Unknown method: {request.method_name}")

    async def _get_user(self, request: GrpcRequest) -> GrpcResponse:
        """Handle GetUser RPC."""
        # 1. Deserialize request
        req = userservice_pb2.GetUserRequest()
        req.ParseFromString(request.payload)

        # 2. Validate input
        if req.id <= 0:
            raise ValueError("User ID must be positive")

        # 3. Business logic
        user = await self.user_repository.find_by_id(req.id)
        if not user:
            raise ValueError(f"User {req.id} not found")

        # 4. Build response
        response_user = userservice_pb2.User()
        response_user.id = user.id
        response_user.name = user.name
        response_user.email = user.email
        response_user.created_at = user.created_at.isoformat()

        # 5. Serialize and return
        return GrpcResponse(
            payload=response_user.SerializeToString(),
            metadata={"x-user-found": "true"}
        )

    async def _create_user(self, request: GrpcRequest) -> GrpcResponse:
        """Handle CreateUser RPC."""
        # 1. Deserialize request
        req = userservice_pb2.CreateUserRequest()
        req.ParseFromString(request.payload)

        # 2. Validate input
        if not req.name or not req.email:
            raise ValueError("Name and email are required")

        # 3. Check authorization from metadata
        auth_token = request.get_metadata("authorization")
        if not auth_token:
            raise PermissionError("Authentication required")

        # 4. Business logic
        user = await self.user_repository.create(
            name=req.name,
            email=req.email
        )

        # 5. Build response
        response_user = userservice_pb2.User()
        response_user.id = user.id
        response_user.name = user.name
        response_user.email = user.email
        response_user.created_at = datetime.utcnow().isoformat()

        # 6. Serialize with metadata
        return GrpcResponse(
            payload=response_user.SerializeToString(),
            metadata={
                "x-user-id": str(user.id),
                "x-created": "true"
            }
        )

Class-Based Handler

import {
  GrpcHandler,
  GrpcRequest,
  GrpcResponse,
  GrpcError,
  GrpcStatusCode,
  createServiceHandler,
  createUnaryHandler,
} from 'spikard';
import * as userservice from './userservice_pb';  // Generated protobufjs types

class UserServiceHandler implements GrpcHandler {
  constructor(private userRepository: UserRepository) {}

  async handleRequest(request: GrpcRequest): Promise<GrpcResponse> {
    /**
     * Handle incoming gRPC requests.
     * Routes to appropriate method based on request.methodName.
     */
    switch (request.methodName) {
      case 'GetUser':
        return this.getUser(request);
      case 'CreateUser':
        return this.createUser(request);
      default:
        throw new GrpcError(
          GrpcStatusCode.UNIMPLEMENTED,
          `Method ${request.methodName} not implemented`
        );
    }
  }

  private async getUser(request: GrpcRequest): Promise<GrpcResponse> {
    // 1. Deserialize request
    const req = userservice.GetUserRequest.decode(request.payload);

    // 2. Validate input
    if (req.id <= 0) {
      throw new GrpcError(
        GrpcStatusCode.INVALID_ARGUMENT,
        'User ID must be positive'
      );
    }

    // 3. Business logic
    const user = await this.userRepository.findById(req.id);
    if (!user) {
      throw new GrpcError(
        GrpcStatusCode.NOT_FOUND,
        `User ${req.id} not found`
      );
    }

    // 4. Build response
    const responseUser = userservice.User.create({
      id: user.id,
      name: user.name,
      email: user.email,
      createdAt: user.createdAt.toISOString(),
    });

    // 5. Serialize and return
    const encoded = userservice.User.encode(responseUser).finish();
    return {
      payload: Buffer.from(encoded),
      metadata: { 'x-user-found': 'true' },
    };
  }

  private async createUser(request: GrpcRequest): Promise<GrpcResponse> {
    // 1. Deserialize request
    const req = userservice.CreateUserRequest.decode(request.payload);

    // 2. Validate input
    if (!req.name || !req.email) {
      throw new GrpcError(
        GrpcStatusCode.INVALID_ARGUMENT,
        'Name and email are required'
      );
    }

    // 3. Check authorization from metadata
    const authToken = request.metadata['authorization'];
    if (!authToken) {
      throw new GrpcError(
        GrpcStatusCode.UNAUTHENTICATED,
        'Authentication required'
      );
    }

    // 4. Business logic
    const user = await this.userRepository.create({
      name: req.name,
      email: req.email,
    });

    // 5. Build response
    const responseUser = userservice.User.create({
      id: user.id,
      name: user.name,
      email: user.email,
      createdAt: new Date().toISOString(),
    });

    // 6. Serialize with metadata
    const encoded = userservice.User.encode(responseUser).finish();
    return {
      payload: Buffer.from(encoded),
      metadata: {
        'x-user-id': user.id.toString(),
        'x-created': 'true',
      },
    };
  }
}

Helper Function Pattern (Alternative)

For simpler handlers, use the factory helper pattern:

// Create handler using helper function
const userServiceHandler = createServiceHandler({
  GetUser: createUnaryHandler<GetUserRequest, User>(
    'GetUser',
    async (req, metadata) => {
      // Validate
      if (req.id <= 0) {
        throw new GrpcError(GrpcStatusCode.INVALID_ARGUMENT, 'Invalid ID');
      }

      // Business logic
      const user = await userRepository.findById(req.id);
      if (!user) {
        throw new GrpcError(GrpcStatusCode.NOT_FOUND, 'User not found');
      }

      // Return with metadata
      return {
        response: user,
        metadata: { 'x-user-found': 'true' },
      };
    },
    userservice.GetUserRequest,
    userservice.User
  ),

  CreateUser: createUnaryHandler<CreateUserRequest, User>(
    'CreateUser',
    async (req, metadata) => {
      // Validate
      if (!req.name || !req.email) {
        throw new GrpcError(GrpcStatusCode.INVALID_ARGUMENT, 'Missing fields');
      }

      // Check auth
      if (!metadata['authorization']) {
        throw new GrpcError(GrpcStatusCode.UNAUTHENTICATED, 'Auth required');
      }

      // Business logic
      const user = await userRepository.create(req);

      return {
        response: user,
        metadata: { 'x-created': 'true' },
      };
    },
    userservice.CreateUserRequest,
    userservice.User
  ),
});

When to use each: - Class-based: Complex services with shared state, dependency injection, multiple methods - Helper functions: Simple services, functional style, less boilerplate

require 'spikard/grpc'
require 'userservice_pb'  # Generated from proto

class UserServiceHandler < Spikard::Grpc::Handler
  def initialize(user_repository)
    @user_repository = user_repository
  end

  def handle_request(request)
    # Route based on method name
    case request.method_name
    when 'GetUser'
      get_user(request)
    when 'CreateUser'
      create_user(request)
    else
      raise "Unknown method: #{request.method_name}"
    end
  end

  private

  def get_user(request)
    # 1. Deserialize request
    req = Userservice::GetUserRequest.decode(request.payload)

    # 2. Validate input
    raise ArgumentError, 'User ID must be positive' if req.id <= 0

    # 3. Business logic
    user = @user_repository.find_by_id(req.id)
    raise ArgumentError, "User #{req.id} not found" unless user

    # 4. Build response
    response_user = Userservice::User.new(
      id: user.id,
      name: user.name,
      email: user.email,
      created_at: user.created_at.iso8601
    )

    # 5. Serialize and return
    response = Spikard::Grpc::Response.new(
      payload: Userservice::User.encode(response_user)
    )
    response.metadata = { 'x-user-found' => 'true' }
    response
  end

  def create_user(request)
    # 1. Deserialize request
    req = Userservice::CreateUserRequest.decode(request.payload)

    # 2. Validate input
    if req.name.empty? || req.email.empty?
      raise ArgumentError, 'Name and email are required'
    end

    # 3. Check authorization from metadata
    auth_token = request.get_metadata('authorization')
    unless auth_token
      raise SecurityError, 'Authentication required'
    end

    # 4. Business logic
    user = @user_repository.create(
      name: req.name,
      email: req.email
    )

    # 5. Build response
    response_user = Userservice::User.new(
      id: user.id,
      name: user.name,
      email: user.email,
      created_at: Time.now.utc.iso8601
    )

    # 6. Serialize with metadata
    response = Spikard::Grpc::Response.new(
      payload: Userservice::User.encode(response_user)
    )
    response.metadata = {
      'x-user-id' => user.id.to_s,
      'x-created' => 'true'
    }
    response
  end
end
<?php declare(strict_types=1);

use Spikard\Grpc\HandlerInterface;
use Spikard\Grpc\Request;
use Spikard\Grpc\Response;
use Userservice\GetUserRequest;
use Userservice\CreateUserRequest;
use Userservice\User;

class UserServiceHandler implements HandlerInterface
{
    public function __construct(
        private UserRepository $userRepository,
    ) {}

    public function handleRequest(Request $request): Response
    {
        // Route based on method name
        return match ($request->methodName) {
            'GetUser' => $this->getUser($request),
            'CreateUser' => $this->createUser($request),
            default => Response::error("Unknown method: {$request->methodName}"),
        };
    }

    private function getUser(Request $request): Response
    {
        try {
            // 1. Deserialize request
            $req = new GetUserRequest();
            $req->mergeFromString($request->payload);

            // 2. Validate input
            if ($req->getId() <= 0) {
                return Response::error('User ID must be positive');
            }

            // 3. Business logic
            $user = $this->userRepository->findById($req->getId());
            if (!$user) {
                return Response::error("User {$req->getId()} not found");
            }

            // 4. Build response
            $responseUser = new User();
            $responseUser->setId($user->getId());
            $responseUser->setName($user->getName());
            $responseUser->setEmail($user->getEmail());
            $responseUser->setCreatedAt($user->getCreatedAt()->format('c'));

            // 5. Serialize and return
            return new Response(
                payload: $responseUser->serializeToString(),
                metadata: ['x-user-found' => 'true']
            );

        } catch (\Exception $e) {
            return Response::error("Error: {$e->getMessage()}");
        }
    }

    private function createUser(Request $request): Response
    {
        try {
            // 1. Deserialize request
            $req = new CreateUserRequest();
            $req->mergeFromString($request->payload);

            // 2. Validate input
            if (empty($req->getName()) || empty($req->getEmail())) {
                return Response::error('Name and email are required');
            }

            // 3. Check authorization from metadata
            $authToken = $request->getMetadata('authorization');
            if (!$authToken) {
                return Response::error(
                    'Authentication required',
                    'UNAUTHENTICATED'
                );
            }

            // 4. Business logic
            $user = $this->userRepository->create(
                name: $req->getName(),
                email: $req->getEmail()
            );

            // 5. Build response
            $responseUser = new User();
            $responseUser->setId($user->getId());
            $responseUser->setName($user->getName());
            $responseUser->setEmail($user->getEmail());
            $responseUser->setCreatedAt((new \DateTime())->format('c'));

            // 6. Serialize with metadata
            return new Response(
                payload: $responseUser->serializeToString(),
                metadata: [
                    'x-user-id' => (string)$user->getId(),
                    'x-created' => 'true',
                ]
            );

        } catch (\Exception $e) {
            return Response::error("Error: {$e->getMessage()}");
        }
    }
}
use bytes::Bytes;
use spikard_http::grpc::{GrpcHandler, GrpcHandlerResult, GrpcRequestData, GrpcResponseData};
use tonic::Status;
use std::sync::Arc;

// Generated protobuf types
mod userservice {
    include!("userservice.rs");  // Generated by prost
}

pub struct UserServiceHandler {
    user_repository: Arc<dyn UserRepository + Send + Sync>,
}

impl UserServiceHandler {
    pub fn new(user_repository: Arc<dyn UserRepository + Send + Sync>) -> Self {
        Self { user_repository }
    }

    async fn get_user(&self, request: GrpcRequestData) -> GrpcHandlerResult {
        // 1. Deserialize request
        use prost::Message;
        let req = userservice::GetUserRequest::decode(request.payload)
            .map_err(|e| Status::invalid_argument(format!("Invalid request: {}", e)))?;

        // 2. Validate input
        if req.id <= 0 {
            return Err(Status::invalid_argument("User ID must be positive"));
        }

        // 3. Business logic
        let user = self.user_repository.find_by_id(req.id).await
            .map_err(|e| Status::internal(format!("Database error: {}", e)))?
            .ok_or_else(|| Status::not_found(format!("User {} not found", req.id)))?;

        // 4. Build response
        let response_user = userservice::User {
            id: user.id,
            name: user.name.clone(),
            email: user.email.clone(),
            created_at: user.created_at.to_rfc3339(),
        };

        // 5. Serialize
        let mut buf = Vec::new();
        response_user.encode(&mut buf)
            .map_err(|e| Status::internal(format!("Encoding error: {}", e)))?;

        // 6. Add metadata
        let mut metadata = tonic::metadata::MetadataMap::new();
        metadata.insert("x-user-found", "true".parse()
            .map_err(|e| Status::internal(format!("Invalid metadata value: {}", e)))?
        );

        Ok(GrpcResponseData {
            payload: Bytes::from(buf),
            metadata,
        })
    }

    async fn create_user(&self, request: GrpcRequestData) -> GrpcHandlerResult {
        // 1. Deserialize request
        use prost::Message;
        let req = userservice::CreateUserRequest::decode(request.payload)
            .map_err(|e| Status::invalid_argument(format!("Invalid request: {}", e)))?;

        // 2. Validate input
        if req.name.is_empty() || req.email.is_empty() {
            return Err(Status::invalid_argument("Name and email are required"));
        }

        // 3. Check authorization from metadata
        let auth_token = request.metadata
            .get("authorization")
            .and_then(|v| v.to_str().ok());

        if auth_token.is_none() {
            return Err(Status::unauthenticated("Authentication required"));
        }

        // 4. Business logic
        let user = self.user_repository.create(&req.name, &req.email).await
            .map_err(|e| Status::internal(format!("Create failed: {}", e)))?;

        // 5. Build response
        let response_user = userservice::User {
            id: user.id,
            name: user.name.clone(),
            email: user.email.clone(),
            created_at: chrono::Utc::now().to_rfc3339(),
        };

        // 6. Serialize
        let mut buf = Vec::new();
        response_user.encode(&mut buf)
            .map_err(|e| Status::internal(format!("Encoding error: {}", e)))?;

        // 7. Add metadata
        let mut metadata = tonic::metadata::MetadataMap::new();
        metadata.insert("x-user-id", user.id.to_string().parse()
            .map_err(|e| Status::internal(format!("Invalid metadata value: {}", e)))?
        );
        metadata.insert("x-created", "true".parse()
            .map_err(|e| Status::internal(format!("Invalid metadata value: {}", e)))?
        );

        Ok(GrpcResponseData {
            payload: Bytes::from(buf),
            metadata,
        })
    }
}

impl GrpcHandler for UserServiceHandler {
    fn call(&self, request: GrpcRequestData) -> Pin<Box<dyn Future<Output = GrpcHandlerResult> + Send>> {
        Box::pin(async move {
            match request.method_name.as_str() {
                "GetUser" => self.get_user(request).await,
                "CreateUser" => self.create_user(request).await,
                _ => Err(Status::unimplemented(format!("Unknown method: {}", request.method_name))),
            }
        })
    }

    fn service_name(&self) -> &'static str {
        "userservice.UserService"
    }
}

Common Patterns

Key Patterns by Language

  • Async/await: All handlers are async for non-blocking I/O
  • ParseFromString(): Deserializes binary protobuf to Python object
  • SerializeToString(): Serializes Python object to binary protobuf
  • Exception mapping: ValueError -> INVALID_ARGUMENT, PermissionError -> PERMISSION_DENIED
  • Metadata access: request.get_metadata(key) returns str | None
  • Protobufjs: Uses .decode() and .encode().finish() for serialization
  • Buffer: gRPC payloads are Node.js Buffer objects
  • GrpcError: Throw with explicit status codes for proper error responses
  • Helper functions: createUnaryHandler and createServiceHandler reduce boilerplate
  • Type safety: Full TypeScript type inference for protobuf messages
  • Synchronous: Ruby handlers are synchronous (Rust runtime handles async)
  • .decode() / .encode(): Ruby protobuf methods for serialization
  • Metadata access: request.get_metadata(key) returns String | nil
  • Response construction: Create response, then set metadata separately
  • Error responses: Use Response.error() for error cases
  • Exception mapping: Rescue exceptions and convert to gRPC status codes
  • Synchronous: PHP handlers are synchronous
  • mergeFromString(): Deserializes binary protobuf (use merge, not parse)
  • serializeToString(): Serializes protobuf to binary
  • Getters/Setters: PHP protobuf uses getter/setter methods
  • Error responses: Return Response::error() instead of throwing
  • Named arguments: PHP 8.0+ named arguments for clarity
  • Type hints: Leverage PHP type system for safety
  • Async/await: Handlers return Pin<Box<dyn Future>>
  • prost: Uses .decode() and .encode() for protobuf
  • Error handling: Return tonic::Status directly
  • Zero-copy: Uses Bytes for efficient payload handling
  • Type safety: Full compile-time type checking
  • Arc: Shared ownership for thread-safe repository access

Error Handling

try:
    # Handler logic
    pass
except ValueError as e:
    # Maps to INVALID_ARGUMENT
    raise
except PermissionError as e:
    # Maps to PERMISSION_DENIED
    raise
except NotImplementedError as e:
    # Maps to UNIMPLEMENTED
    raise
except Exception as e:
    # Maps to INTERNAL
    raise

Mapping is automatic via FFI layer (pyerr_to_grpc_status).

import { GrpcError, GrpcStatusCode } from 'spikard';

// Explicit status codes
throw new GrpcError(GrpcStatusCode.INVALID_ARGUMENT, 'Invalid ID');
throw new GrpcError(GrpcStatusCode.NOT_FOUND, 'User not found');
throw new GrpcError(GrpcStatusCode.UNAUTHENTICATED, 'Auth required');
throw new GrpcError(GrpcStatusCode.PERMISSION_DENIED, 'Access denied');
throw new GrpcError(GrpcStatusCode.INTERNAL, 'Internal error');

Explicit GrpcError for all status codes.

class UserServiceHandler < Spikard::Grpc::Handler
  def handle_request(request)
    case request.method_name
    when 'GetUser'
      get_user(request)
    else
      # Return error response
      Spikard::Grpc::Response.error(
        "Method not implemented: #{request.method_name}"
      )
    end
  rescue ArgumentError => e
    # Invalid argument error
    Spikard::Grpc::Response.error(e.message, 'INVALID_ARGUMENT')
  rescue SecurityError => e
    # Authentication error
    Spikard::Grpc::Response.error(e.message, 'UNAUTHENTICATED')
  rescue StandardError => e
    # Internal error
    Spikard::Grpc::Response.error("Internal error: #{e.message}")
  end
end
<?php declare(strict_types=1);

// Return error response
return Response::error('Error message');

// With status code in metadata
return Response::error(
    'Error message',
    'INVALID_ARGUMENT'
);

// Try-catch pattern
try {
    // Handler logic
} catch (\InvalidArgumentException $e) {
    return Response::error($e->getMessage());
} catch (\Exception $e) {
    return Response::error("Internal error: {$e->getMessage()}");
}

Return error responses instead of throwing.

// Direct tonic::Status
return Err(Status::invalid_argument("Invalid ID"));
return Err(Status::not_found("User not found"));
return Err(Status::unauthenticated("Auth required"));
return Err(Status::permission_denied("Access denied"));
return Err(Status::internal("Internal error"));

// With .map_err()
user_repository.find_by_id(id)
    .await
    .map_err(|e| Status::internal(format!("DB error: {}", e)))?;

Type-safe Result<T, Status> pattern.

Status Codes Reference

gRPC Status Codes Reference

Complete reference table for all 17 standard gRPC status codes.

Code Numeric When to Use HTTP Equivalent
OK 0 Request completed successfully 200 OK
CANCELLED 1 Operation was cancelled (typically by caller) 499 Client Closed Request
UNKNOWN 2 Unknown error or unmapped status from another system 500 Internal Server Error
INVALID_ARGUMENT 3 Client specified an invalid argument (validation errors) 400 Bad Request
DEADLINE_EXCEEDED 4 Operation deadline was exceeded before completion 504 Gateway Timeout
NOT_FOUND 5 Requested entity (e.g., file, user) was not found 404 Not Found
ALREADY_EXISTS 6 Entity that client attempted to create already exists 409 Conflict
PERMISSION_DENIED 7 Caller lacks permission for the operation 403 Forbidden
RESOURCE_EXHAUSTED 8 Resource has been exhausted (quota, rate limit) 429 Too Many Requests
FAILED_PRECONDITION 9 Operation rejected because system not in required state 400 Bad Request
ABORTED 10 Operation aborted due to concurrency issues 409 Conflict
OUT_OF_RANGE 11 Operation attempted past valid range 400 Bad Request
UNIMPLEMENTED 12 Operation is not implemented or not supported 501 Not Implemented
INTERNAL 13 Internal server error 500 Internal Server Error
UNAVAILABLE 14 Service is currently unavailable (temporary condition) 503 Service Unavailable
DATA_LOSS 15 Unrecoverable data loss or corruption 500 Internal Server Error
UNAUTHENTICATED 16 Request missing or invalid authentication credentials 401 Unauthorized

Usage Guidelines

  1. Choose the most specific code: Use the most descriptive status code that accurately represents the error condition.

  2. Provide helpful messages: Include clear, actionable error messages that help clients understand and resolve the issue.

  3. Never expose sensitive information: Don't include stack traces, database errors, or internal system details in error messages.

  4. Use INTERNAL for unexpected errors: When encountering unexpected server errors, return INTERNAL and log the details server-side.

  5. Distinguish UNAUTHENTICATED vs PERMISSION_DENIED: Use UNAUTHENTICATED for missing/invalid credentials, PERMISSION_DENIED for authenticated users lacking permissions.

  6. Consider retry behavior: Clients may automatically retry certain codes (UNAVAILABLE, DEADLINE_EXCEEDED) but not others (INVALID_ARGUMENT, PERMISSION_DENIED).


Next Steps

  1. Streaming RPCs: Server, client, and bidirectional streaming
  2. Authentication: Implement auth using metadata headers
  3. Observability: Add request tracing and logging

Learn More


Summary

You've learned:

  1. What Spikard gRPC is: Handler-focused gRPC with a shared Rust runtime
  2. How to write .proto files: Define messages and services
  3. Code generation: Use protoc to generate language-specific types
  4. Handler implementation: Deserialize -> Process -> Serialize pattern
  5. Testing: Write comprehensive tests for your handlers

Key Takeaway: Spikard gRPC lets you focus on business logic. The runtime handles HTTP/2, gRPC protocol, routing, and status codes.