diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..48bdb02 --- /dev/null +++ b/.dockerignore @@ -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/ \ No newline at end of file diff --git a/.github-template/README.md b/.github-template/README.md new file mode 100644 index 0000000..3d0d5cd --- /dev/null +++ b/.github-template/README.md @@ -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 \ No newline at end of file diff --git a/.github-template/workflows/ci.yml b/.github-template/workflows/ci.yml new file mode 100644 index 0000000..d801a3d --- /dev/null +++ b/.github-template/workflows/ci.yml @@ -0,0 +1,72 @@ +# Copy this file to /.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 \ No newline at end of file diff --git a/.github-template/workflows/docker.yml b/.github-template/workflows/docker.yml new file mode 100644 index 0000000..4603bc6 --- /dev/null +++ b/.github-template/workflows/docker.yml @@ -0,0 +1,68 @@ +# Copy this file to /.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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c189b82 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md index ce571da..6c67323 100644 --- a/README.md +++ b/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 \ No newline at end of file +**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) \ No newline at end of file diff --git a/buf.gen.yaml b/buf.gen.yaml index c42e73f..360edad 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -14,4 +14,9 @@ plugins: - local: protoc-gen-grpc-gateway out: . opt: - - paths=source_relative \ No newline at end of file + - paths=source_relative + # generate OpenAPI documentation + - local: protoc-gen-openapiv2 + out: docs + opt: + - logtostderr=true \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 95208be..01bcccc 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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() } \ No newline at end of file diff --git a/coverage.html b/coverage.html new file mode 100644 index 0000000..805c790 --- /dev/null +++ b/coverage.html @@ -0,0 +1,1041 @@ + + + + + + server: Go Coverage Report + + + +
+ +
+ not tracked + + not covered + covered + +
+
+
+ + + + + + + + + + + + + + + +
+ + + diff --git a/docs/proto/helloworld/hello_world.swagger.json b/docs/proto/helloworld/hello_world.swagger.json new file mode 100644 index 0000000..ae5ee41 --- /dev/null +++ b/docs/proto/helloworld/hello_world.swagger.json @@ -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" + } + } + } + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..28826e7 --- /dev/null +++ b/internal/config/config.go @@ -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 +} \ No newline at end of file diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..435db4e --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } + }) + } +} \ No newline at end of file diff --git a/internal/health/health.go b/internal/health/health.go new file mode 100644 index 0000000..43cf521 --- /dev/null +++ b/internal/health/health.go @@ -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() +} \ No newline at end of file diff --git a/internal/health/health_test.go b/internal/health/health_test.go new file mode 100644 index 0000000..d4c5cd5 --- /dev/null +++ b/internal/health/health_test.go @@ -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) + } + }) +} \ No newline at end of file diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..b5eb826 --- /dev/null +++ b/internal/server/server.go @@ -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 + } +} \ No newline at end of file diff --git a/justfile b/justfile index 1471256..f7d5cd7 100644 --- a/justfile +++ b/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="; 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 diff --git a/tests/integration_test.go b/tests/integration_test.go new file mode 100644 index 0000000..b1e2e29 --- /dev/null +++ b/tests/integration_test.go @@ -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"]) + } + }) +} \ No newline at end of file