feat: first
This commit is contained in:
50
.dockerignore
Normal file
50
.dockerignore
Normal 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/
|
||||
64
.github-template/README.md
Normal file
64
.github-template/README.md
Normal 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
|
||||
72
.github-template/workflows/ci.yml
Normal file
72
.github-template/workflows/ci.yml
Normal 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
|
||||
68
.github-template/workflows/docker.yml
Normal file
68
.github-template/workflows/docker.yml
Normal 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
56
Dockerfile
Normal 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
289
README.md
@@ -1,68 +1,267 @@
|
||||
# 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/
|
||||
│ ├── handler/ # gRPC and gateway handlers
|
||||
│ └── service/ # Business logic
|
||||
├── pkg/pb/ # Generated protobuf files (auto-generated)
|
||||
├── api/v1/ # Protocol buffer definitions
|
||||
└── justfile # Task runner configuration
|
||||
│ ├── config/ # Configuration management
|
||||
│ ├── health/ # Health check handlers
|
||||
│ └── server/ # Server setup and middleware
|
||||
├── pkg/ # Shared packages (if needed)
|
||||
├── 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+
|
||||
- Protocol Buffers compiler (`protoc`)
|
||||
- [just](https://github.com/casey/just) task runner
|
||||
- [nushell](https://www.nushell.sh/) (for justfile execution)
|
||||
- **Go 1.23+**
|
||||
- **Docker & Docker Compose** (for containerized development)
|
||||
- **[just](https://github.com/casey/just)** task runner
|
||||
- **[buf](https://buf.build)** (installed automatically via `just install-deps`)
|
||||
|
||||
## Getting Started
|
||||
## 🚦 Quick Start
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
just deps
|
||||
```
|
||||
### 1. Install Dependencies
|
||||
```bash
|
||||
just install-deps
|
||||
```
|
||||
|
||||
2. Generate protobuf files:
|
||||
```bash
|
||||
just proto
|
||||
```
|
||||
### 2. Start Development Environment
|
||||
```bash
|
||||
# Start with Docker (recommended)
|
||||
just dev-docker
|
||||
|
||||
3. Build and run the server:
|
||||
```bash
|
||||
just dev
|
||||
```
|
||||
# Or start locally
|
||||
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 build` - Build the server binary
|
||||
- `just run` - Run the server
|
||||
- `just dev` - Full development workflow (deps + proto + run)
|
||||
- `just test` - Run tests
|
||||
- `just lint` - Run linter
|
||||
|
||||
### Testing & Quality
|
||||
- `just test` - Run unit tests
|
||||
- `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 docs` - Generate and serve API documentation
|
||||
- `just health` - Check service health
|
||||
|
||||
## API Endpoints
|
||||
## 🌐 API Documentation
|
||||
|
||||
The server runs on two ports:
|
||||
- gRPC server: `:8080`
|
||||
- HTTP gateway: `:8081`
|
||||
### Endpoints
|
||||
|
||||
### Example HTTP endpoints:
|
||||
- `GET /v1/examples` - List all examples
|
||||
- `POST /v1/examples` - Create a new example
|
||||
- `GET /v1/examples/{id}` - Get a specific example
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/v1/example/echo` | Echo the input name |
|
||||
| `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:
|
||||
- Excluding shared proto folders
|
||||
- Using internal packages for service-specific logic
|
||||
- Minimal external dependencies
|
||||
- Clear separation of concerns
|
||||
**Echo Request:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8090/v1/example/echo \
|
||||
-H "Content-Type: application/json" \
|
||||
-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)
|
||||
@@ -15,3 +15,8 @@ plugins:
|
||||
out: .
|
||||
opt:
|
||||
- paths=source_relative
|
||||
# generate OpenAPI documentation
|
||||
- local: protoc-gen-openapiv2
|
||||
out: docs
|
||||
opt:
|
||||
- logtostderr=true
|
||||
@@ -3,82 +3,45 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
helloworldpb "go-grpc-gateway-template/proto/helloworld"
|
||||
"go-grpc-gateway-template/internal/config"
|
||||
"go-grpc-gateway-template/internal/server"
|
||||
)
|
||||
|
||||
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() {
|
||||
// Create a listener on TCP port
|
||||
lis, err := net.Listen("tcp", ":8080")
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to listen:", err)
|
||||
// Load configuration
|
||||
cfg := config.Load()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
log.Fatalf("Invalid configuration: %v", err)
|
||||
}
|
||||
|
||||
// Create a gRPC server object
|
||||
s := grpc.NewServer()
|
||||
// Attach the Greeter service to the server
|
||||
helloworldpb.RegisterGreeterServer(s, &server{})
|
||||
// Create server
|
||||
srv := server.New(cfg)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
// Setup graceful shutdown
|
||||
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() {
|
||||
defer wg.Done()
|
||||
if err := s.Serve(lis); err != nil {
|
||||
log.Fatalln("Failed to serve:", err)
|
||||
}
|
||||
<-sigChan
|
||||
log.Println("Received shutdown signal")
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Create a client connection to the gRPC server we just started
|
||||
// This is where the gRPC-Gateway proxies the requests
|
||||
conn, err := grpc.DialContext(
|
||||
context.Background(),
|
||||
"0.0.0.0:8080",
|
||||
grpc.WithBlock(),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to dial server:", err)
|
||||
// Start server
|
||||
if err := srv.Start(ctx); err != nil {
|
||||
log.Printf("Server error: %v", err)
|
||||
}
|
||||
|
||||
gwmux := runtime.NewServeMux()
|
||||
// Register Greeter
|
||||
err = helloworldpb.RegisterGreeterHandler(context.Background(), gwmux, conn)
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to register gateway:", err)
|
||||
// Shutdown server
|
||||
if err := srv.Shutdown(context.Background()); err != nil {
|
||||
log.Printf("Shutdown error: %v", 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
1041
coverage.html
Normal file
File diff suppressed because it is too large
Load Diff
98
docs/proto/helloworld/hello_world.swagger.json
Normal file
98
docs/proto/helloworld/hello_world.swagger.json
Normal 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
100
internal/config/config.go
Normal 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
|
||||
}
|
||||
193
internal/config/config_test.go
Normal file
193
internal/config/config_test.go
Normal 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
112
internal/health/health.go
Normal 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()
|
||||
}
|
||||
115
internal/health/health_test.go
Normal file
115
internal/health/health_test.go
Normal 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
205
internal/server/server.go
Normal 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
|
||||
}
|
||||
}
|
||||
52
justfile
52
justfile
@@ -19,7 +19,9 @@ run: build
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf bin/
|
||||
rm -rf pkg/pb/
|
||||
rm -rf proto/**/*.pb.go
|
||||
rm -rf proto/**/*.pb.gw.go
|
||||
rm -rf docs/
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
@@ -45,6 +47,7 @@ install-deps:
|
||||
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
|
||||
|
||||
# Download and tidy dependencies
|
||||
deps:
|
||||
@@ -53,3 +56,50 @@ deps:
|
||||
|
||||
# Development workflow
|
||||
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
120
tests/integration_test.go
Normal 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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user