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
|
# Go gRPC Gateway Template
|
||||||
|
|
||||||
A simplified Go project template for gRPC services with HTTP/JSON gateway support, based on the [Go Standard Project Layout](https://github.com/golang-standards/project-layout).
|
A production-ready Go template for gRPC services with HTTP/JSON gateway support, featuring modern tooling, Docker support, and CI/CD integration.
|
||||||
|
|
||||||
## Project Structure
|
## 🚀 Features
|
||||||
|
|
||||||
|
- **Dual Protocol Support** - Serve both gRPC and REST APIs simultaneously
|
||||||
|
- **Modern Toolchain** - Uses [buf](https://buf.build) for Protocol Buffers management
|
||||||
|
- **Docker Ready** - Complete containerization with multi-stage builds
|
||||||
|
- **Testing** - Unit and integration tests with coverage
|
||||||
|
- **CI/CD** - GitHub Actions workflows for testing and deployment
|
||||||
|
- **Health Checks** - Built-in health monitoring endpoints
|
||||||
|
- **OpenAPI** - Auto-generated Swagger documentation
|
||||||
|
- **Configuration** - Environment-based config management
|
||||||
|
- **Observability** - Structured logging and metrics ready
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
├── cmd/server/ # Main application entry point
|
├── cmd/server/ # Main application entry point
|
||||||
|
├── proto/helloworld/ # Protocol buffer definitions
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── handler/ # gRPC and gateway handlers
|
│ ├── config/ # Configuration management
|
||||||
│ └── service/ # Business logic
|
│ ├── health/ # Health check handlers
|
||||||
├── pkg/pb/ # Generated protobuf files (auto-generated)
|
│ └── server/ # Server setup and middleware
|
||||||
├── api/v1/ # Protocol buffer definitions
|
├── pkg/ # Shared packages (if needed)
|
||||||
└── justfile # Task runner configuration
|
├── tests/ # Integration tests
|
||||||
|
├── docker/ # Docker configuration
|
||||||
|
├── .github/workflows/ # CI/CD pipelines
|
||||||
|
├── buf.yaml # Buf configuration
|
||||||
|
├── buf.gen.yaml # Code generation config
|
||||||
|
├── docker-compose.yml # Local development
|
||||||
|
└── justfile # Task runner
|
||||||
```
|
```
|
||||||
|
|
||||||
## Prerequisites
|
## 🛠 Prerequisites
|
||||||
|
|
||||||
- Go 1.21+
|
- **Go 1.23+**
|
||||||
- Protocol Buffers compiler (`protoc`)
|
- **Docker & Docker Compose** (for containerized development)
|
||||||
- [just](https://github.com/casey/just) task runner
|
- **[just](https://github.com/casey/just)** task runner
|
||||||
- [nushell](https://www.nushell.sh/) (for justfile execution)
|
- **[buf](https://buf.build)** (installed automatically via `just install-deps`)
|
||||||
|
|
||||||
## Getting Started
|
## 🚦 Quick Start
|
||||||
|
|
||||||
1. Install dependencies:
|
### 1. Install Dependencies
|
||||||
```bash
|
```bash
|
||||||
just deps
|
just install-deps
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Generate protobuf files:
|
### 2. Start Development Environment
|
||||||
```bash
|
```bash
|
||||||
just proto
|
# Start with Docker (recommended)
|
||||||
```
|
just dev-docker
|
||||||
|
|
||||||
3. Build and run the server:
|
# Or start locally
|
||||||
```bash
|
just dev
|
||||||
just dev
|
```
|
||||||
```
|
|
||||||
|
|
||||||
## Available Commands
|
### 3. Test the API
|
||||||
|
```bash
|
||||||
|
# gRPC endpoint
|
||||||
|
grpcurl -plaintext -d '{"name": "World"}' localhost:8080 helloworld.Greeter/SayHello
|
||||||
|
|
||||||
|
# HTTP endpoint
|
||||||
|
curl -X POST http://localhost:8090/v1/example/echo -d '{"name": "World"}' -H "Content-Type: application/json"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Available Commands
|
||||||
|
|
||||||
|
### Development
|
||||||
|
- `just install-deps` - Install required tools (buf, protoc plugins)
|
||||||
|
- `just dev` - Start development server locally
|
||||||
|
- `just dev-docker` - Start with Docker Compose
|
||||||
- `just proto` - Generate protobuf files
|
- `just proto` - Generate protobuf files
|
||||||
- `just build` - Build the server binary
|
- `just build` - Build the server binary
|
||||||
- `just run` - Run the server
|
|
||||||
- `just dev` - Full development workflow (deps + proto + run)
|
### Testing & Quality
|
||||||
- `just test` - Run tests
|
- `just test` - Run unit tests
|
||||||
- `just lint` - Run linter
|
- `just test-integration` - Run integration tests
|
||||||
|
- `just test-coverage` - Run tests with coverage report
|
||||||
|
- `just lint` - Run linter and format check
|
||||||
|
- `just format` - Format code
|
||||||
|
|
||||||
|
### Docker & Deployment
|
||||||
|
- `just docker-build` - Build Docker image
|
||||||
|
- `just docker-run` - Run container locally
|
||||||
|
- `just docker-push` - Push to registry
|
||||||
|
|
||||||
|
### Utilities
|
||||||
- `just clean` - Clean build artifacts
|
- `just clean` - Clean build artifacts
|
||||||
|
- `just docs` - Generate and serve API documentation
|
||||||
|
- `just health` - Check service health
|
||||||
|
|
||||||
## API Endpoints
|
## 🌐 API Documentation
|
||||||
|
|
||||||
The server runs on two ports:
|
### Endpoints
|
||||||
- gRPC server: `:8080`
|
|
||||||
- HTTP gateway: `:8081`
|
|
||||||
|
|
||||||
### Example HTTP endpoints:
|
| Method | Path | Description |
|
||||||
- `GET /v1/examples` - List all examples
|
|--------|------|-------------|
|
||||||
- `POST /v1/examples` - Create a new example
|
| `POST` | `/v1/example/echo` | Echo the input name |
|
||||||
- `GET /v1/examples/{id}` - Get a specific example
|
| `GET` | `/health` | Health check endpoint |
|
||||||
|
| `GET` | `/metrics` | Prometheus metrics (if enabled) |
|
||||||
|
| `GET` | `/docs` | OpenAPI documentation |
|
||||||
|
|
||||||
## Monorepo Integration
|
### Example Requests
|
||||||
|
|
||||||
This template is designed to be integration-friendly for monorepo structures by:
|
**Echo Request:**
|
||||||
- Excluding shared proto folders
|
```bash
|
||||||
- Using internal packages for service-specific logic
|
curl -X POST http://localhost:8090/v1/example/echo \
|
||||||
- Minimal external dependencies
|
-H "Content-Type: application/json" \
|
||||||
- Clear separation of concerns
|
-d '{"name": "World"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Hello World"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Health Check:**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8090/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"timestamp": "2025-08-13T19:30:00Z",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐳 Docker Usage
|
||||||
|
|
||||||
|
### Development with Docker Compose
|
||||||
|
```bash
|
||||||
|
# Start all services
|
||||||
|
just dev-docker
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Stop services
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Docker Build
|
||||||
|
```bash
|
||||||
|
# Build optimized image
|
||||||
|
just docker-build
|
||||||
|
|
||||||
|
# Run container
|
||||||
|
just docker-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
just test
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
just test-coverage
|
||||||
|
|
||||||
|
# Run integration tests
|
||||||
|
just test-integration
|
||||||
|
|
||||||
|
# Run specific test
|
||||||
|
go test -v ./internal/server -run TestServerHealth
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
The service can be configured via environment variables:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `SERVER_GRPC_PORT` | `8080` | gRPC server port |
|
||||||
|
| `SERVER_HTTP_PORT` | `8090` | HTTP gateway port |
|
||||||
|
| `LOG_LEVEL` | `info` | Logging level (debug, info, warn, error) |
|
||||||
|
| `LOG_FORMAT` | `json` | Log format (json, text) |
|
||||||
|
| `METRICS_ENABLED` | `true` | Enable Prometheus metrics |
|
||||||
|
|
||||||
|
Example `.env` file:
|
||||||
|
```bash
|
||||||
|
SERVER_GRPC_PORT=9090
|
||||||
|
SERVER_HTTP_PORT=8080
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
LOG_FORMAT=text
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Adding New Services
|
||||||
|
|
||||||
|
1. **Define the service in protobuf:**
|
||||||
|
```protobuf
|
||||||
|
service UserService {
|
||||||
|
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
post: "/v1/users"
|
||||||
|
body: "*"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Generate code:**
|
||||||
|
```bash
|
||||||
|
just proto
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Implement the service:**
|
||||||
|
```go
|
||||||
|
type UserService struct {
|
||||||
|
helloworldpb.UnimplementedUserServiceServer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) CreateUser(ctx context.Context, req *helloworldpb.CreateUserRequest) (*helloworldpb.CreateUserResponse, error) {
|
||||||
|
// Implementation here
|
||||||
|
return &helloworldpb.CreateUserResponse{}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Register in server:**
|
||||||
|
```go
|
||||||
|
helloworldpb.RegisterUserServiceServer(s, &UserService{})
|
||||||
|
helloworldpb.RegisterUserServiceHandler(ctx, gwmux, conn)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Deployment
|
||||||
|
|
||||||
|
### GitHub Actions
|
||||||
|
|
||||||
|
The template includes automated workflows:
|
||||||
|
- **CI**: Runs tests, linting, and security scans on PRs
|
||||||
|
- **CD**: Builds and pushes Docker images on releases
|
||||||
|
- **Dependency Updates**: Automated dependency updates
|
||||||
|
|
||||||
|
### Manual Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build for production
|
||||||
|
just build
|
||||||
|
|
||||||
|
# Create Docker image
|
||||||
|
just docker-build
|
||||||
|
|
||||||
|
# Deploy to your platform
|
||||||
|
just docker-push
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Run tests: `just test`
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
|
||||||
|
## 🔗 Useful Links
|
||||||
|
|
||||||
|
- [gRPC-Gateway Documentation](https://grpc-ecosystem.github.io/grpc-gateway/)
|
||||||
|
- [Protocol Buffers Guide](https://developers.google.com/protocol-buffers)
|
||||||
|
- [Buf Documentation](https://buf.build/docs/)
|
||||||
|
- [Go Standard Project Layout](https://github.com/golang-standards/project-layout)
|
||||||
@@ -14,4 +14,9 @@ plugins:
|
|||||||
- local: protoc-gen-grpc-gateway
|
- local: protoc-gen-grpc-gateway
|
||||||
out: .
|
out: .
|
||||||
opt:
|
opt:
|
||||||
- paths=source_relative
|
- paths=source_relative
|
||||||
|
# generate OpenAPI documentation
|
||||||
|
- local: protoc-gen-openapiv2
|
||||||
|
out: docs
|
||||||
|
opt:
|
||||||
|
- logtostderr=true
|
||||||
@@ -3,82 +3,45 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"os"
|
||||||
"net/http"
|
"os/signal"
|
||||||
"sync"
|
"syscall"
|
||||||
|
|
||||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
"go-grpc-gateway-template/internal/config"
|
||||||
"google.golang.org/grpc"
|
"go-grpc-gateway-template/internal/server"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
|
||||||
|
|
||||||
helloworldpb "go-grpc-gateway-template/proto/helloworld"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type server struct {
|
|
||||||
helloworldpb.UnimplementedGreeterServer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) SayHello(ctx context.Context, req *helloworldpb.HelloRequest) (*helloworldpb.HelloReply, error) {
|
|
||||||
return &helloworldpb.HelloReply{
|
|
||||||
Message: "Hello " + req.GetName(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Create a listener on TCP port
|
// Load configuration
|
||||||
lis, err := net.Listen("tcp", ":8080")
|
cfg := config.Load()
|
||||||
if err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
log.Fatalln("Failed to listen:", err)
|
log.Fatalf("Invalid configuration: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a gRPC server object
|
// Create server
|
||||||
s := grpc.NewServer()
|
srv := server.New(cfg)
|
||||||
// Attach the Greeter service to the server
|
|
||||||
helloworldpb.RegisterGreeterServer(s, &server{})
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
// Setup graceful shutdown
|
||||||
wg.Add(2)
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Handle signals
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
// Serve gRPC server
|
|
||||||
log.Println("Serving gRPC on 0.0.0.0:8080")
|
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
<-sigChan
|
||||||
if err := s.Serve(lis); err != nil {
|
log.Println("Received shutdown signal")
|
||||||
log.Fatalln("Failed to serve:", err)
|
cancel()
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Create a client connection to the gRPC server we just started
|
// Start server
|
||||||
// This is where the gRPC-Gateway proxies the requests
|
if err := srv.Start(ctx); err != nil {
|
||||||
conn, err := grpc.DialContext(
|
log.Printf("Server error: %v", err)
|
||||||
context.Background(),
|
|
||||||
"0.0.0.0:8080",
|
|
||||||
grpc.WithBlock(),
|
|
||||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalln("Failed to dial server:", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gwmux := runtime.NewServeMux()
|
// Shutdown server
|
||||||
// Register Greeter
|
if err := srv.Shutdown(context.Background()); err != nil {
|
||||||
err = helloworldpb.RegisterGreeterHandler(context.Background(), gwmux, conn)
|
log.Printf("Shutdown error: %v", err)
|
||||||
if err != nil {
|
|
||||||
log.Fatalln("Failed to register gateway:", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gwServer := &http.Server{
|
|
||||||
Addr: ":8090",
|
|
||||||
Handler: gwmux,
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Serving gRPC-Gateway on http://0.0.0.0:8090")
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
if err := gwServer.ListenAndServe(); err != nil {
|
|
||||||
log.Fatalln("Failed to serve gRPC-Gateway:", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
}
|
||||||
1041
coverage.html
Normal file
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 build artifacts
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin/
|
rm -rf bin/
|
||||||
rm -rf pkg/pb/
|
rm -rf proto/**/*.pb.go
|
||||||
|
rm -rf proto/**/*.pb.gw.go
|
||||||
|
rm -rf docs/
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
test:
|
test:
|
||||||
@@ -45,6 +47,7 @@ install-deps:
|
|||||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
|
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
|
||||||
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest
|
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest
|
||||||
|
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest
|
||||||
|
|
||||||
# Download and tidy dependencies
|
# Download and tidy dependencies
|
||||||
deps:
|
deps:
|
||||||
@@ -53,3 +56,50 @@ deps:
|
|||||||
|
|
||||||
# Development workflow
|
# Development workflow
|
||||||
dev: deps proto run
|
dev: deps proto run
|
||||||
|
|
||||||
|
# Docker commands
|
||||||
|
docker-build:
|
||||||
|
docker build -t grpc-gateway-template:latest .
|
||||||
|
|
||||||
|
docker-run:
|
||||||
|
docker run --rm -p 8080:8080 -p 8090:8090 grpc-gateway-template:latest
|
||||||
|
|
||||||
|
docker-push registry="":
|
||||||
|
@if [ -z "{{registry}}" ]; then echo "Usage: just docker-push registry=<registry>"; exit 1; fi
|
||||||
|
docker tag grpc-gateway-template:latest {{registry}}/grpc-gateway-template:latest
|
||||||
|
docker push {{registry}}/grpc-gateway-template:latest
|
||||||
|
|
||||||
|
# Testing commands
|
||||||
|
test-coverage:
|
||||||
|
go test -coverprofile=coverage.out ./...
|
||||||
|
go tool cover -html=coverage.out -o coverage.html
|
||||||
|
|
||||||
|
test-integration:
|
||||||
|
go test -tags=integration ./tests/...
|
||||||
|
|
||||||
|
# Formatting and linting
|
||||||
|
format:
|
||||||
|
go fmt ./...
|
||||||
|
goimports -w .
|
||||||
|
|
||||||
|
# Health check command
|
||||||
|
health:
|
||||||
|
@curl -f http://localhost:8090/health || echo "Service not healthy"
|
||||||
|
|
||||||
|
# Generate and serve API documentation
|
||||||
|
docs: proto
|
||||||
|
@echo "OpenAPI documentation generated in docs/"
|
||||||
|
@if command -v python3 >/dev/null 2>&1; then \
|
||||||
|
echo "Serving docs at http://localhost:8080/docs"; \
|
||||||
|
cd docs && python3 -m http.server 8080; \
|
||||||
|
else \
|
||||||
|
echo "Install Python 3 to serve docs locally"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Development with hot reload (requires air)
|
||||||
|
dev-watch:
|
||||||
|
@if ! command -v air >/dev/null 2>&1; then \
|
||||||
|
echo "Installing air for hot reload..."; \
|
||||||
|
go install github.com/cosmtrek/air@latest; \
|
||||||
|
fi
|
||||||
|
air
|
||||||
|
|||||||
120
tests/integration_test.go
Normal file
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