feat: first

This commit is contained in:
2025-08-13 19:36:09 +02:00
parent fd2ba2e999
commit 5fc4d1d997
17 changed files with 2622 additions and 111 deletions

50
.dockerignore Normal file
View File

@@ -0,0 +1,50 @@
# Ignore development and build artifacts
.git
.gitignore
README.md
Dockerfile
.dockerignore
docker-compose.yml
# Go build artifacts
bin/
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
# Generated protobuf files (will be generated in container)
proto/**/*.pb.go
proto/**/*.pb.gw.go
# Buf files
buf.lock
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Node modules (if any)
node_modules/
# Logs
*.log
# Temporary files
tmp/
temp/

View File

@@ -0,0 +1,64 @@
# GitHub Actions Workflows
This directory contains template GitHub Actions workflows for CI/CD.
## Setup Instructions
### For Standalone Repository
Copy the workflow files to `.github/workflows/` in your repository root:
```bash
mkdir -p .github/workflows
cp .github-template/workflows/* .github/workflows/
```
### For Monorepo
1. Copy the workflow files to your monorepo's `.github/workflows/` directory
2. Update the path filters in each workflow file to match your app location
3. Update the `working-directory` and `context` paths as indicated in the comments
Example for an app at `apps/my-grpc-service/`:
```yaml
# Add path filters
on:
push:
paths:
- 'apps/my-grpc-service/**'
# Update working directory for Go commands
- name: Run tests
run: go test -v ./...
working-directory: ./apps/my-grpc-service
# Update Docker context
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: ./apps/my-grpc-service
```
## Workflows Included
### ci.yml
- Runs on push/PR to main/develop branches
- Tests, linting, building, and coverage reporting
- Supports Go 1.23
- Uses buf for protobuf generation
### docker.yml
- Builds and pushes Docker images
- Multi-platform builds (amd64/arm64)
- Pushes to GitHub Container Registry
- Triggered on pushes to main and tags
## Required Secrets
No additional secrets are required - workflows use:
- `GITHUB_TOKEN` (automatically provided)
- GitHub Container Registry (ghcr.io)
## Customization
- Adjust Go version in ci.yml
- Change registry in docker.yml (e.g., Docker Hub, AWS ECR)
- Add deployment jobs for your platform
- Add security scanning steps

View File

@@ -0,0 +1,72 @@
# Copy this file to <monorepo>/.github/workflows/ and adjust paths as needed
# For monorepo: change paths to match your app location (e.g., apps/your-app/**)
name: CI
on:
push:
branches: [ main, develop ]
# For monorepo, uncomment and adjust:
# paths:
# - 'apps/your-grpc-app/**'
pull_request:
branches: [ main, develop ]
# For monorepo, uncomment and adjust:
# paths:
# - 'apps/your-grpc-app/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23'
# For monorepo, adjust working-directory:
# working-directory: ./apps/your-grpc-app
- name: Install buf
run: |
BUF_VERSION="1.28.1"
curl -sSL "https://github.com/bufbuild/buf/releases/download/v${BUF_VERSION}/buf-$(uname -s)-$(uname -m)" -o "/usr/local/bin/buf"
chmod +x "/usr/local/bin/buf"
- name: Install protoc plugins
run: |
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest
- name: Download dependencies
run: go mod download
- name: Generate protobuf code
run: |
buf dep update
buf generate
- name: Run tests
run: go test -v -race -coverprofile=coverage.out ./...
- name: Run linter
uses: golangci/golangci-lint-action@v3
with:
version: latest
# For monorepo:
# working-directory: ./apps/your-grpc-app
- name: Build
run: go build -v ./cmd/server
- name: Upload coverage reports
uses: codecov/codecov-action@v3
with:
file: ./coverage.out
# For monorepo:
# directory: ./apps/your-grpc-app

View File

@@ -0,0 +1,68 @@
# Copy this file to <monorepo>/.github/workflows/ and adjust paths as needed
# For monorepo: change context and paths to match your app location
name: Docker Build & Push
on:
push:
branches: [ main ]
tags: [ 'v*' ]
# For monorepo, uncomment and adjust:
# paths:
# - 'apps/your-grpc-app/**'
pull_request:
branches: [ main ]
# For monorepo, uncomment and adjust:
# paths:
# - 'apps/your-grpc-app/**'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}/grpc-gateway-app
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
# For monorepo, adjust context:
# context: ./apps/your-grpc-app
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

56
Dockerfile Normal file
View File

@@ -0,0 +1,56 @@
# Multi-stage build for optimized production image
FROM golang:1.23-alpine AS builder
# Install build dependencies including buf and protoc
RUN apk add --no-cache git ca-certificates tzdata curl && \
curl -sSL "https://github.com/bufbuild/buf/releases/latest/download/buf-$(uname -s)-$(uname -m)" -o /usr/local/bin/buf && \
chmod +x /usr/local/bin/buf
# Set working directory
WORKDIR /build
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Install protoc plugins
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest && \
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest && \
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest
# Copy source code
COPY . .
# Generate protobuf code
RUN buf dep update && buf generate
# Build the binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags='-w -s -extldflags "-static"' \
-tags netgo -installsuffix netgo \
-o server cmd/server/main.go
# Final stage: minimal runtime image
FROM scratch
# Copy CA certificates for HTTPS requests
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy timezone data
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# Copy the binary
COPY --from=builder /build/server /server
# Expose ports
EXPOSE 8080 8090
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["/server", "health"] || exit 1
# Run the binary
ENTRYPOINT ["/server"]

289
README.md
View File

@@ -1,68 +1,267 @@
# Go gRPC Gateway Template # Go gRPC Gateway Template
A simplified Go project template for gRPC services with HTTP/JSON gateway support, based on the [Go Standard Project Layout](https://github.com/golang-standards/project-layout). A production-ready Go template for gRPC services with HTTP/JSON gateway support, featuring modern tooling, Docker support, and CI/CD integration.
## Project Structure ## 🚀 Features
- **Dual Protocol Support** - Serve both gRPC and REST APIs simultaneously
- **Modern Toolchain** - Uses [buf](https://buf.build) for Protocol Buffers management
- **Docker Ready** - Complete containerization with multi-stage builds
- **Testing** - Unit and integration tests with coverage
- **CI/CD** - GitHub Actions workflows for testing and deployment
- **Health Checks** - Built-in health monitoring endpoints
- **OpenAPI** - Auto-generated Swagger documentation
- **Configuration** - Environment-based config management
- **Observability** - Structured logging and metrics ready
## 📁 Project Structure
``` ```
├── cmd/server/ # Main application entry point ├── cmd/server/ # Main application entry point
├── proto/helloworld/ # Protocol buffer definitions
├── internal/ ├── internal/
│ ├── handler/ # gRPC and gateway handlers │ ├── config/ # Configuration management
── service/ # Business logic ── health/ # Health check handlers
├── pkg/pb/ # Generated protobuf files (auto-generated) │ └── server/ # Server setup and middleware
├── api/v1/ # Protocol buffer definitions ├── pkg/ # Shared packages (if needed)
── justfile # Task runner configuration ── tests/ # Integration tests
├── docker/ # Docker configuration
├── .github/workflows/ # CI/CD pipelines
├── buf.yaml # Buf configuration
├── buf.gen.yaml # Code generation config
├── docker-compose.yml # Local development
└── justfile # Task runner
``` ```
## Prerequisites ## 🛠 Prerequisites
- Go 1.21+ - **Go 1.23+**
- Protocol Buffers compiler (`protoc`) - **Docker & Docker Compose** (for containerized development)
- [just](https://github.com/casey/just) task runner - **[just](https://github.com/casey/just)** task runner
- [nushell](https://www.nushell.sh/) (for justfile execution) - **[buf](https://buf.build)** (installed automatically via `just install-deps`)
## Getting Started ## 🚦 Quick Start
1. Install dependencies: ### 1. Install Dependencies
```bash ```bash
just deps just install-deps
``` ```
2. Generate protobuf files: ### 2. Start Development Environment
```bash ```bash
just proto # Start with Docker (recommended)
``` just dev-docker
3. Build and run the server: # Or start locally
```bash just dev
just dev ```
```
## Available Commands ### 3. Test the API
```bash
# gRPC endpoint
grpcurl -plaintext -d '{"name": "World"}' localhost:8080 helloworld.Greeter/SayHello
# HTTP endpoint
curl -X POST http://localhost:8090/v1/example/echo -d '{"name": "World"}' -H "Content-Type: application/json"
```
## 📋 Available Commands
### Development
- `just install-deps` - Install required tools (buf, protoc plugins)
- `just dev` - Start development server locally
- `just dev-docker` - Start with Docker Compose
- `just proto` - Generate protobuf files - `just proto` - Generate protobuf files
- `just build` - Build the server binary - `just build` - Build the server binary
- `just run` - Run the server
- `just dev` - Full development workflow (deps + proto + run) ### Testing & Quality
- `just test` - Run tests - `just test` - Run unit tests
- `just lint` - Run linter - `just test-integration` - Run integration tests
- `just test-coverage` - Run tests with coverage report
- `just lint` - Run linter and format check
- `just format` - Format code
### Docker & Deployment
- `just docker-build` - Build Docker image
- `just docker-run` - Run container locally
- `just docker-push` - Push to registry
### Utilities
- `just clean` - Clean build artifacts - `just clean` - Clean build artifacts
- `just docs` - Generate and serve API documentation
- `just health` - Check service health
## API Endpoints ## 🌐 API Documentation
The server runs on two ports: ### Endpoints
- gRPC server: `:8080`
- HTTP gateway: `:8081`
### Example HTTP endpoints: | Method | Path | Description |
- `GET /v1/examples` - List all examples |--------|------|-------------|
- `POST /v1/examples` - Create a new example | `POST` | `/v1/example/echo` | Echo the input name |
- `GET /v1/examples/{id}` - Get a specific example | `GET` | `/health` | Health check endpoint |
| `GET` | `/metrics` | Prometheus metrics (if enabled) |
| `GET` | `/docs` | OpenAPI documentation |
## Monorepo Integration ### Example Requests
This template is designed to be integration-friendly for monorepo structures by: **Echo Request:**
- Excluding shared proto folders ```bash
- Using internal packages for service-specific logic curl -X POST http://localhost:8090/v1/example/echo \
- Minimal external dependencies -H "Content-Type: application/json" \
- Clear separation of concerns -d '{"name": "World"}'
```
**Response:**
```json
{
"message": "Hello World"
}
```
**Health Check:**
```bash
curl http://localhost:8090/health
```
**Response:**
```json
{
"status": "ok",
"timestamp": "2025-08-13T19:30:00Z",
"version": "1.0.0"
}
```
## 🐳 Docker Usage
### Development with Docker Compose
```bash
# Start all services
just dev-docker
# View logs
docker-compose logs -f
# Stop services
docker-compose down
```
### Production Docker Build
```bash
# Build optimized image
just docker-build
# Run container
just docker-run
```
## 🧪 Testing
```bash
# Run all tests
just test
# Run with coverage
just test-coverage
# Run integration tests
just test-integration
# Run specific test
go test -v ./internal/server -run TestServerHealth
```
## 🔧 Configuration
The service can be configured via environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `SERVER_GRPC_PORT` | `8080` | gRPC server port |
| `SERVER_HTTP_PORT` | `8090` | HTTP gateway port |
| `LOG_LEVEL` | `info` | Logging level (debug, info, warn, error) |
| `LOG_FORMAT` | `json` | Log format (json, text) |
| `METRICS_ENABLED` | `true` | Enable Prometheus metrics |
Example `.env` file:
```bash
SERVER_GRPC_PORT=9090
SERVER_HTTP_PORT=8080
LOG_LEVEL=debug
LOG_FORMAT=text
```
## 🔄 Adding New Services
1. **Define the service in protobuf:**
```protobuf
service UserService {
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) {
option (google.api.http) = {
post: "/v1/users"
body: "*"
};
}
}
```
2. **Generate code:**
```bash
just proto
```
3. **Implement the service:**
```go
type UserService struct {
helloworldpb.UnimplementedUserServiceServer
}
func (s *UserService) CreateUser(ctx context.Context, req *helloworldpb.CreateUserRequest) (*helloworldpb.CreateUserResponse, error) {
// Implementation here
return &helloworldpb.CreateUserResponse{}, nil
}
```
4. **Register in server:**
```go
helloworldpb.RegisterUserServiceServer(s, &UserService{})
helloworldpb.RegisterUserServiceHandler(ctx, gwmux, conn)
```
## 🚀 Deployment
### GitHub Actions
The template includes automated workflows:
- **CI**: Runs tests, linting, and security scans on PRs
- **CD**: Builds and pushes Docker images on releases
- **Dependency Updates**: Automated dependency updates
### Manual Deployment
```bash
# Build for production
just build
# Create Docker image
just docker-build
# Deploy to your platform
just docker-push
```
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Run tests: `just test`
5. Submit a pull request
## 🔗 Useful Links
- [gRPC-Gateway Documentation](https://grpc-ecosystem.github.io/grpc-gateway/)
- [Protocol Buffers Guide](https://developers.google.com/protocol-buffers)
- [Buf Documentation](https://buf.build/docs/)
- [Go Standard Project Layout](https://github.com/golang-standards/project-layout)

View File

@@ -15,3 +15,8 @@ plugins:
out: . out: .
opt: opt:
- paths=source_relative - paths=source_relative
# generate OpenAPI documentation
- local: protoc-gen-openapiv2
out: docs
opt:
- logtostderr=true

View File

@@ -3,82 +3,45 @@ package main
import ( import (
"context" "context"
"log" "log"
"net" "os"
"net/http" "os/signal"
"sync" "syscall"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime" "go-grpc-gateway-template/internal/config"
"google.golang.org/grpc" "go-grpc-gateway-template/internal/server"
"google.golang.org/grpc/credentials/insecure"
helloworldpb "go-grpc-gateway-template/proto/helloworld"
) )
type server struct {
helloworldpb.UnimplementedGreeterServer
}
func (s *server) SayHello(ctx context.Context, req *helloworldpb.HelloRequest) (*helloworldpb.HelloReply, error) {
return &helloworldpb.HelloReply{
Message: "Hello " + req.GetName(),
}, nil
}
func main() { func main() {
// Create a listener on TCP port // Load configuration
lis, err := net.Listen("tcp", ":8080") cfg := config.Load()
if err != nil { if err := cfg.Validate(); err != nil {
log.Fatalln("Failed to listen:", err) log.Fatalf("Invalid configuration: %v", err)
} }
// Create a gRPC server object // Create server
s := grpc.NewServer() srv := server.New(cfg)
// Attach the Greeter service to the server
helloworldpb.RegisterGreeterServer(s, &server{})
var wg sync.WaitGroup // Setup graceful shutdown
wg.Add(2) ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Handle signals
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Serve gRPC server
log.Println("Serving gRPC on 0.0.0.0:8080")
go func() { go func() {
defer wg.Done() <-sigChan
if err := s.Serve(lis); err != nil { log.Println("Received shutdown signal")
log.Fatalln("Failed to serve:", err) cancel()
}
}() }()
// Create a client connection to the gRPC server we just started // Start server
// This is where the gRPC-Gateway proxies the requests if err := srv.Start(ctx); err != nil {
conn, err := grpc.DialContext( log.Printf("Server error: %v", err)
context.Background(),
"0.0.0.0:8080",
grpc.WithBlock(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatalln("Failed to dial server:", err)
} }
gwmux := runtime.NewServeMux() // Shutdown server
// Register Greeter if err := srv.Shutdown(context.Background()); err != nil {
err = helloworldpb.RegisterGreeterHandler(context.Background(), gwmux, conn) log.Printf("Shutdown error: %v", err)
if err != nil {
log.Fatalln("Failed to register gateway:", err)
} }
gwServer := &http.Server{
Addr: ":8090",
Handler: gwmux,
}
log.Println("Serving gRPC-Gateway on http://0.0.0.0:8090")
go func() {
defer wg.Done()
if err := gwServer.ListenAndServe(); err != nil {
log.Fatalln("Failed to serve gRPC-Gateway:", err)
}
}()
wg.Wait()
} }

1041
coverage.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
{
"swagger": "2.0",
"info": {
"title": "proto/helloworld/hello_world.proto",
"version": "version not set"
},
"tags": [
{
"name": "Greeter"
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"paths": {
"/v1/example/echo": {
"post": {
"operationId": "Greeter_SayHello",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/helloworldHelloReply"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/helloworldHelloRequest"
}
}
],
"tags": [
"Greeter"
]
}
}
},
"definitions": {
"helloworldHelloReply": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
},
"helloworldHelloRequest": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
}
},
"protobufAny": {
"type": "object",
"properties": {
"@type": {
"type": "string"
}
},
"additionalProperties": {}
},
"rpcStatus": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"details": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/protobufAny"
}
}
}
}
}
}

100
internal/config/config.go Normal file
View File

@@ -0,0 +1,100 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
)
// Config holds all configuration for the application
type Config struct {
Server ServerConfig
Log LogConfig
Metrics MetricsConfig
}
// ServerConfig holds server-related configuration
type ServerConfig struct {
GRPCPort string
HTTPPort string
}
// LogConfig holds logging configuration
type LogConfig struct {
Level string
Format string
}
// MetricsConfig holds metrics configuration
type MetricsConfig struct {
Enabled bool
}
// Load loads configuration from environment variables with defaults
func Load() *Config {
return &Config{
Server: ServerConfig{
GRPCPort: getEnv("SERVER_GRPC_PORT", "8080"),
HTTPPort: getEnv("SERVER_HTTP_PORT", "8090"),
},
Log: LogConfig{
Level: strings.ToLower(getEnv("LOG_LEVEL", "info")),
Format: strings.ToLower(getEnv("LOG_FORMAT", "json")),
},
Metrics: MetricsConfig{
Enabled: getEnvBool("METRICS_ENABLED", true),
},
}
}
// Validate validates the configuration
func (c *Config) Validate() error {
if c.Server.GRPCPort == "" {
return fmt.Errorf("gRPC port cannot be empty")
}
if c.Server.HTTPPort == "" {
return fmt.Errorf("HTTP port cannot be empty")
}
if c.Server.GRPCPort == c.Server.HTTPPort {
return fmt.Errorf("gRPC and HTTP ports cannot be the same")
}
validLogLevels := map[string]bool{
"debug": true,
"info": true,
"warn": true,
"error": true,
}
if !validLogLevels[c.Log.Level] {
return fmt.Errorf("invalid log level: %s", c.Log.Level)
}
validLogFormats := map[string]bool{
"json": true,
"text": true,
}
if !validLogFormats[c.Log.Format] {
return fmt.Errorf("invalid log format: %s", c.Log.Format)
}
return nil
}
// getEnv gets an environment variable or returns a default value
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getEnvBool gets a boolean environment variable or returns a default value
func getEnvBool(key string, defaultValue bool) bool {
if value := os.Getenv(key); value != "" {
if parsed, err := strconv.ParseBool(value); err == nil {
return parsed
}
}
return defaultValue
}

View File

@@ -0,0 +1,193 @@
package config
import (
"os"
"testing"
)
func TestLoad(t *testing.T) {
// Save original env vars
originalGRPCPort := os.Getenv("SERVER_GRPC_PORT")
originalHTTPPort := os.Getenv("SERVER_HTTP_PORT")
originalLogLevel := os.Getenv("LOG_LEVEL")
originalLogFormat := os.Getenv("LOG_FORMAT")
originalMetricsEnabled := os.Getenv("METRICS_ENABLED")
// Clean up after test
defer func() {
os.Setenv("SERVER_GRPC_PORT", originalGRPCPort)
os.Setenv("SERVER_HTTP_PORT", originalHTTPPort)
os.Setenv("LOG_LEVEL", originalLogLevel)
os.Setenv("LOG_FORMAT", originalLogFormat)
os.Setenv("METRICS_ENABLED", originalMetricsEnabled)
}()
tests := []struct {
name string
envVars map[string]string
expected Config
}{
{
name: "default values",
envVars: map[string]string{},
expected: Config{
Server: ServerConfig{
GRPCPort: "8080",
HTTPPort: "8090",
},
Log: LogConfig{
Level: "info",
Format: "json",
},
Metrics: MetricsConfig{
Enabled: true,
},
},
},
{
name: "custom values",
envVars: map[string]string{
"SERVER_GRPC_PORT": "9090",
"SERVER_HTTP_PORT": "9091",
"LOG_LEVEL": "debug",
"LOG_FORMAT": "text",
"METRICS_ENABLED": "false",
},
expected: Config{
Server: ServerConfig{
GRPCPort: "9090",
HTTPPort: "9091",
},
Log: LogConfig{
Level: "debug",
Format: "text",
},
Metrics: MetricsConfig{
Enabled: false,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Clear all env vars first
os.Unsetenv("SERVER_GRPC_PORT")
os.Unsetenv("SERVER_HTTP_PORT")
os.Unsetenv("LOG_LEVEL")
os.Unsetenv("LOG_FORMAT")
os.Unsetenv("METRICS_ENABLED")
// Set test env vars
for key, value := range tt.envVars {
os.Setenv(key, value)
}
cfg := Load()
if cfg.Server.GRPCPort != tt.expected.Server.GRPCPort {
t.Errorf("expected GRPCPort %s, got %s", tt.expected.Server.GRPCPort, cfg.Server.GRPCPort)
}
if cfg.Server.HTTPPort != tt.expected.Server.HTTPPort {
t.Errorf("expected HTTPPort %s, got %s", tt.expected.Server.HTTPPort, cfg.Server.HTTPPort)
}
if cfg.Log.Level != tt.expected.Log.Level {
t.Errorf("expected LogLevel %s, got %s", tt.expected.Log.Level, cfg.Log.Level)
}
if cfg.Log.Format != tt.expected.Log.Format {
t.Errorf("expected LogFormat %s, got %s", tt.expected.Log.Format, cfg.Log.Format)
}
if cfg.Metrics.Enabled != tt.expected.Metrics.Enabled {
t.Errorf("expected MetricsEnabled %t, got %t", tt.expected.Metrics.Enabled, cfg.Metrics.Enabled)
}
})
}
}
func TestValidate(t *testing.T) {
tests := []struct {
name string
config Config
wantErr bool
}{
{
name: "valid config",
config: Config{
Server: ServerConfig{
GRPCPort: "8080",
HTTPPort: "8090",
},
Log: LogConfig{
Level: "info",
Format: "json",
},
},
wantErr: false,
},
{
name: "empty grpc port",
config: Config{
Server: ServerConfig{
GRPCPort: "",
HTTPPort: "8090",
},
Log: LogConfig{
Level: "info",
Format: "json",
},
},
wantErr: true,
},
{
name: "same ports",
config: Config{
Server: ServerConfig{
GRPCPort: "8080",
HTTPPort: "8080",
},
Log: LogConfig{
Level: "info",
Format: "json",
},
},
wantErr: true,
},
{
name: "invalid log level",
config: Config{
Server: ServerConfig{
GRPCPort: "8080",
HTTPPort: "8090",
},
Log: LogConfig{
Level: "invalid",
Format: "json",
},
},
wantErr: true,
},
{
name: "invalid log format",
config: Config{
Server: ServerConfig{
GRPCPort: "8080",
HTTPPort: "8090",
},
Log: LogConfig{
Level: "info",
Format: "invalid",
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

112
internal/health/health.go Normal file
View File

@@ -0,0 +1,112 @@
package health
import (
"encoding/json"
"net/http"
"time"
)
// Response represents a health check response
type Response struct {
Status string `json:"status"`
Timestamp time.Time `json:"timestamp"`
Version string `json:"version,omitempty"`
Checks []Check `json:"checks,omitempty"`
}
// Check represents an individual health check
type Check struct {
Name string `json:"name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
// Handler handles health check requests
type Handler struct {
version string
checks []HealthChecker
}
// HealthChecker interface for health checks
type HealthChecker interface {
Name() string
Check() error
}
// NewHandler creates a new health check handler
func NewHandler(version string) *Handler {
return &Handler{
version: version,
checks: make([]HealthChecker, 0),
}
}
// AddCheck adds a health checker
func (h *Handler) AddCheck(checker HealthChecker) {
h.checks = append(h.checks, checker)
}
// ServeHTTP implements http.Handler
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
response := Response{
Status: "ok",
Timestamp: time.Now().UTC(),
Version: h.version,
Checks: make([]Check, 0, len(h.checks)),
}
allHealthy := true
for _, checker := range h.checks {
check := Check{
Name: checker.Name(),
Status: "ok",
}
if err := checker.Check(); err != nil {
check.Status = "error"
check.Error = err.Error()
allHealthy = false
}
response.Checks = append(response.Checks, check)
}
if !allHealthy {
response.Status = "error"
w.WriteHeader(http.StatusServiceUnavailable)
} else {
w.WriteHeader(http.StatusOK)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// SimpleChecker is a basic health checker
type SimpleChecker struct {
name string
fn func() error
}
// NewSimpleChecker creates a new simple health checker
func NewSimpleChecker(name string, fn func() error) *SimpleChecker {
return &SimpleChecker{
name: name,
fn: fn,
}
}
// Name returns the checker name
func (c *SimpleChecker) Name() string {
return c.name
}
// Check performs the health check
func (c *SimpleChecker) Check() error {
return c.fn()
}

View File

@@ -0,0 +1,115 @@
package health
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthHandler_ServeHTTP(t *testing.T) {
tests := []struct {
name string
method string
checkers []HealthChecker
expectedStatus int
expectedHealth string
}{
{
name: "GET request with no checkers",
method: "GET",
checkers: []HealthChecker{},
expectedStatus: http.StatusOK,
expectedHealth: "ok",
},
{
name: "GET request with healthy checker",
method: "GET",
checkers: []HealthChecker{
NewSimpleChecker("test", func() error { return nil }),
},
expectedStatus: http.StatusOK,
expectedHealth: "ok",
},
{
name: "GET request with unhealthy checker",
method: "GET",
checkers: []HealthChecker{
NewSimpleChecker("test", func() error { return errors.New("test error") }),
},
expectedStatus: http.StatusServiceUnavailable,
expectedHealth: "error",
},
{
name: "POST request should return 405",
method: "POST",
checkers: []HealthChecker{},
expectedStatus: http.StatusMethodNotAllowed,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewHandler("1.0.0")
for _, checker := range tt.checkers {
handler.AddCheck(checker)
}
req := httptest.NewRequest(tt.method, "/health", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code)
}
if tt.method == "GET" && tt.expectedStatus != http.StatusMethodNotAllowed {
var response Response
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response.Status != tt.expectedHealth {
t.Errorf("expected status %s, got %s", tt.expectedHealth, response.Status)
}
if response.Version != "1.0.0" {
t.Errorf("expected version 1.0.0, got %s", response.Version)
}
if len(response.Checks) != len(tt.checkers) {
t.Errorf("expected %d checks, got %d", len(tt.checkers), len(response.Checks))
}
}
})
}
}
func TestSimpleChecker(t *testing.T) {
t.Run("healthy checker", func(t *testing.T) {
checker := NewSimpleChecker("test", func() error { return nil })
if checker.Name() != "test" {
t.Errorf("expected name 'test', got %s", checker.Name())
}
if err := checker.Check(); err != nil {
t.Errorf("expected no error, got %v", err)
}
})
t.Run("unhealthy checker", func(t *testing.T) {
testErr := errors.New("test error")
checker := NewSimpleChecker("test", func() error { return testErr })
if checker.Name() != "test" {
t.Errorf("expected name 'test', got %s", checker.Name())
}
if err := checker.Check(); err != testErr {
t.Errorf("expected error %v, got %v", testErr, err)
}
})
}

205
internal/server/server.go Normal file
View File

@@ -0,0 +1,205 @@
package server
import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"sync"
"time"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/reflection"
"go-grpc-gateway-template/internal/config"
"go-grpc-gateway-template/internal/health"
helloworldpb "go-grpc-gateway-template/proto/helloworld"
)
// Server wraps gRPC and HTTP servers with configuration
type Server struct {
config *config.Config
logger *slog.Logger
grpcServer *grpc.Server
httpServer *http.Server
healthHandler *health.Handler
}
// New creates a new server instance
func New(cfg *config.Config) *Server {
// Setup logger
var handler slog.Handler
if cfg.Log.Format == "json" {
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: parseLogLevel(cfg.Log.Level),
})
} else {
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: parseLogLevel(cfg.Log.Level),
})
}
logger := slog.New(handler)
// Create health handler
healthHandler := health.NewHandler("1.0.0")
return &Server{
config: cfg,
logger: logger,
healthHandler: healthHandler,
}
}
// Start starts both gRPC and HTTP servers
func (s *Server) Start(ctx context.Context) error {
var wg sync.WaitGroup
errChan := make(chan error, 2)
// Start gRPC server
wg.Add(1)
go func() {
defer wg.Done()
if err := s.startGRPCServer(ctx); err != nil {
errChan <- fmt.Errorf("gRPC server error: %w", err)
}
}()
// Start HTTP server
wg.Add(1)
go func() {
defer wg.Done()
if err := s.startHTTPServer(ctx); err != nil && err != http.ErrServerClosed {
errChan <- fmt.Errorf("HTTP server error: %w", err)
}
}()
// Wait for servers to start
go func() {
wg.Wait()
close(errChan)
}()
// Return first error if any
return <-errChan
}
func (s *Server) startGRPCServer(ctx context.Context) error {
lis, err := net.Listen("tcp", ":"+s.config.Server.GRPCPort)
if err != nil {
return fmt.Errorf("failed to listen on port %s: %w", s.config.Server.GRPCPort, err)
}
s.grpcServer = grpc.NewServer()
// Register services
greeter := &GreeterService{}
helloworldpb.RegisterGreeterServer(s.grpcServer, greeter)
// Enable reflection for development
reflection.Register(s.grpcServer)
s.logger.Info("Starting gRPC server", "port", s.config.Server.GRPCPort)
return s.grpcServer.Serve(lis)
}
func (s *Server) startHTTPServer(ctx context.Context) error {
// Create gRPC client connection
conn, err := grpc.DialContext(
ctx,
"localhost:"+s.config.Server.GRPCPort,
grpc.WithBlock(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
return fmt.Errorf("failed to dial gRPC server: %w", err)
}
// Create gateway mux
gwMux := runtime.NewServeMux()
// Register gRPC gateway handlers
err = helloworldpb.RegisterGreeterHandler(ctx, gwMux, conn)
if err != nil {
return fmt.Errorf("failed to register gateway: %w", err)
}
// Create HTTP mux with health endpoint
mux := http.NewServeMux()
mux.Handle("/", gwMux)
mux.Handle("/health", s.healthHandler)
s.httpServer = &http.Server{
Addr: ":" + s.config.Server.HTTPPort,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
s.logger.Info("Starting HTTP server", "port", s.config.Server.HTTPPort)
return s.httpServer.ListenAndServe()
}
// Shutdown gracefully shuts down the servers
func (s *Server) Shutdown(ctx context.Context) error {
s.logger.Info("Shutting down servers...")
var wg sync.WaitGroup
// Shutdown HTTP server
if s.httpServer != nil {
wg.Add(1)
go func() {
defer wg.Done()
if err := s.httpServer.Shutdown(ctx); err != nil {
s.logger.Error("HTTP server shutdown error", "error", err)
}
}()
}
// Shutdown gRPC server
if s.grpcServer != nil {
wg.Add(1)
go func() {
defer wg.Done()
s.grpcServer.GracefulStop()
}()
}
wg.Wait()
s.logger.Info("Servers stopped")
return nil
}
// GreeterService implements the helloworld.Greeter service
type GreeterService struct {
helloworldpb.UnimplementedGreeterServer
}
// SayHello implements the SayHello RPC method
func (g *GreeterService) SayHello(ctx context.Context, req *helloworldpb.HelloRequest) (*helloworldpb.HelloReply, error) {
return &helloworldpb.HelloReply{
Message: "Hello " + req.GetName(),
}, nil
}
func parseLogLevel(level string) slog.Level {
switch level {
case "debug":
return slog.LevelDebug
case "info":
return slog.LevelInfo
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}

View File

@@ -19,7 +19,9 @@ run: build
# Clean build artifacts # Clean build artifacts
clean: clean:
rm -rf bin/ rm -rf bin/
rm -rf pkg/pb/ rm -rf proto/**/*.pb.go
rm -rf proto/**/*.pb.gw.go
rm -rf docs/
# Run tests # Run tests
test: test:
@@ -45,6 +47,7 @@ install-deps:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest
# Download and tidy dependencies # Download and tidy dependencies
deps: deps:
@@ -53,3 +56,50 @@ deps:
# Development workflow # Development workflow
dev: deps proto run dev: deps proto run
# Docker commands
docker-build:
docker build -t grpc-gateway-template:latest .
docker-run:
docker run --rm -p 8080:8080 -p 8090:8090 grpc-gateway-template:latest
docker-push registry="":
@if [ -z "{{registry}}" ]; then echo "Usage: just docker-push registry=<registry>"; exit 1; fi
docker tag grpc-gateway-template:latest {{registry}}/grpc-gateway-template:latest
docker push {{registry}}/grpc-gateway-template:latest
# Testing commands
test-coverage:
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
test-integration:
go test -tags=integration ./tests/...
# Formatting and linting
format:
go fmt ./...
goimports -w .
# Health check command
health:
@curl -f http://localhost:8090/health || echo "Service not healthy"
# Generate and serve API documentation
docs: proto
@echo "OpenAPI documentation generated in docs/"
@if command -v python3 >/dev/null 2>&1; then \
echo "Serving docs at http://localhost:8080/docs"; \
cd docs && python3 -m http.server 8080; \
else \
echo "Install Python 3 to serve docs locally"; \
fi
# Development with hot reload (requires air)
dev-watch:
@if ! command -v air >/dev/null 2>&1; then \
echo "Installing air for hot reload..."; \
go install github.com/cosmtrek/air@latest; \
fi
air

120
tests/integration_test.go Normal file
View File

@@ -0,0 +1,120 @@
//go:build integration
package tests
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"go-grpc-gateway-template/internal/config"
"go-grpc-gateway-template/internal/health"
"go-grpc-gateway-template/internal/server"
helloworldpb "go-grpc-gateway-template/proto/helloworld"
)
func TestIntegration(t *testing.T) {
// Create test configuration
cfg := &config.Config{
Server: config.ServerConfig{
GRPCPort: "18080",
HTTPPort: "18090",
},
Log: config.LogConfig{
Level: "info",
Format: "text",
},
Metrics: config.MetricsConfig{
Enabled: false,
},
}
// Start server
srv := server.New(cfg)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
if err := srv.Start(ctx); err != nil {
t.Errorf("Server start error: %v", err)
}
}()
// Wait for server to start
time.Sleep(2 * time.Second)
defer func() {
cancel()
srv.Shutdown(context.Background())
}()
t.Run("Health Check", func(t *testing.T) {
resp, err := http.Get("http://localhost:18090/health")
if err != nil {
t.Fatalf("Health check failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
var healthResp health.Response
if err := json.NewDecoder(resp.Body).Decode(&healthResp); err != nil {
t.Fatalf("Failed to decode health response: %v", err)
}
if healthResp.Status != "ok" {
t.Errorf("Expected status 'ok', got %s", healthResp.Status)
}
})
t.Run("gRPC API", func(t *testing.T) {
conn, err := grpc.Dial("localhost:18080", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatalf("Failed to connect to gRPC server: %v", err)
}
defer conn.Close()
client := helloworldpb.NewGreeterClient(conn)
resp, err := client.SayHello(context.Background(), &helloworldpb.HelloRequest{
Name: "Integration Test",
})
if err != nil {
t.Fatalf("gRPC call failed: %v", err)
}
expectedMessage := "Hello Integration Test"
if resp.Message != expectedMessage {
t.Errorf("Expected message %s, got %s", expectedMessage, resp.Message)
}
})
t.Run("HTTP Gateway", func(t *testing.T) {
reqBody := strings.NewReader(`{"name": "HTTP Test"}`)
resp, err := http.Post("http://localhost:18090/v1/example/echo", "application/json", reqBody)
if err != nil {
t.Fatalf("HTTP request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
expectedMessage := "Hello HTTP Test"
if result["message"] != expectedMessage {
t.Errorf("Expected message %s, got %v", expectedMessage, result["message"])
}
})
}