diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d039640 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,114 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download + + - name: Verify dependencies + run: go mod verify + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + file: ./coverage.out + fail_ci_if_error: false + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + version: latest + args: --timeout=5m + + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download + + - name: Build + run: go build -v -o bin/platform ./cmd/platform + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: platform-binary + path: bin/platform + retention-days: 7 + + fmt: + name: Format Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Check formatting + run: | + if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then + echo "The following files need formatting:" + gofmt -s -d . + exit 1 + fi diff --git a/.gitignore b/.gitignore index a30a249..8eacad2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,61 @@ -# MkDocs build output -docs/site/ -docs/.mkdocs_cache/ - -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -venv/ -env/ -ENV/ - -# Go (if not already present) +# Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib +bin/ +dist/ + +# Test binary, built with `go test -c` *.test + +# Output of the go coverage tool, specifically when used with LiteIDE *.out + +# Dependency directories +vendor/ + +# Go workspace file go.work +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Build artifacts +build/ +*.a +*.o + +# Test coverage files +coverage.txt +coverage.html +*.coverprofile + +# Environment-specific files +.env +.env.local +.env.*.local + +# Logs +*.log +logs/ + +# Temporary files +tmp/ +temp/ + +# Documentation build artifacts +docs/site/ +docs/.mkdocs_cache/ + +# Docker +.dockerignore + +# OS-specific +Thumbs.db \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..44575e7 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,52 @@ +# golangci-lint configuration +# See https://golangci-lint.run/usage/configuration/ + +run: + timeout: 5m + tests: true + modules-download-mode: readonly + +linters: + enable: + - errcheck + - gofmt + - goimports + - govet + - ineffassign + - staticcheck + - typecheck + - unused + - gosimple + - misspell + - revive + - gosec + disable: + - gocritic # Can be enabled later for stricter checks + +linters-settings: + revive: + rules: + - name: exported + severity: warning + - name: package-comments + severity: warning + gosec: + severity: medium + errcheck: + check-blank: true + +issues: + exclude-use-default: false + max-issues-per-linter: 0 + max-same-issues: 0 + exclude-rules: + # Exclude test files from some checks + - path: _test\.go + linters: + - errcheck + - gosec + +output: + format: colored-line-number + print-issued-lines: true + print-linter-name: true diff --git a/Makefile b/Makefile index afa4049..9bf1b47 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,31 @@ -.PHONY: help docs-install docs-serve docs-build docs-deploy docs-clean docs-validate +.PHONY: help test test-coverage lint fmt fmt-check build clean docker-build docker-run generate verify +.PHONY: docs-install docs-serve docs-build docs-deploy docs-clean docs-validate .PHONY: docs-docker-build docs-docker-serve docs-docker-build-site docs-docker-clean docs-docker-compose-up docs-docker-compose-down +# Variables +GO := go +BINARY_NAME := platform +BINARY_PATH := bin/$(BINARY_NAME) +DOCKER_IMAGE := goplt:latest + # Default target help: @echo "Available targets:" @echo "" - @echo "Local commands (require Python/pip):" + @echo "Development commands:" + @echo " make test - Run all tests" + @echo " make test-coverage - Run tests with coverage report" + @echo " make lint - Run linters" + @echo " make fmt - Format code" + @echo " make fmt-check - Check code formatting" + @echo " make build - Build platform binary" + @echo " make clean - Clean build artifacts" + @echo " make docker-build - Build Docker image" + @echo " make docker-run - Run Docker container" + @echo " make generate - Run code generation" + @echo " make verify - Verify code (fmt, lint, test)" + @echo "" + @echo "Documentation commands (require Python/pip):" @echo " make docs-install - Install MkDocs dependencies" @echo " make docs-serve - Serve documentation locally (http://127.0.0.1:8000)" @echo " make docs-build - Build static documentation site" @@ -26,6 +46,73 @@ help: @echo " make build-docs - Alias for docs-build" @echo " make docs-docker - Alias for docs-docker-serve" +# Development commands +test: + @echo "Running tests..." + $(GO) test -v -race ./... + +test-coverage: + @echo "Running tests with coverage..." + $(GO) test -v -race -coverprofile=coverage.out ./... + $(GO) tool cover -html=coverage.out -o coverage.html + @echo "Coverage report generated: coverage.html" + +lint: + @echo "Running linters..." + @if command -v golangci-lint > /dev/null; then \ + golangci-lint run; \ + else \ + echo "golangci-lint not found. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ + exit 1; \ + fi + +fmt: + @echo "Formatting code..." + $(GO) fmt ./... + @if command -v goimports > /dev/null; then \ + goimports -w .; \ + else \ + echo "goimports not found. Install with: go install golang.org/x/tools/cmd/goimports@latest"; \ + fi + +fmt-check: + @echo "Checking code formatting..." + @if [ "$(shell gofmt -s -l . | wc -l)" -gt 0 ]; then \ + echo "The following files need formatting:"; \ + gofmt -s -d .; \ + exit 1; \ + fi + @echo "Code is properly formatted" + +build: + @echo "Building platform binary..." + $(GO) build -v -o $(BINARY_PATH) ./cmd/platform + @echo "Build complete: $(BINARY_PATH)" + +clean: + @echo "Cleaning build artifacts..." + rm -rf bin/ + rm -f coverage.out coverage.html + @echo "Clean complete" + +docker-build: + @echo "Building Docker image..." + docker build -t $(DOCKER_IMAGE) . + @echo "Docker image built: $(DOCKER_IMAGE)" + +docker-run: docker-build + @echo "Running Docker container..." + docker run --rm -it \ + -p 8080:8080 \ + $(DOCKER_IMAGE) + +generate: + @echo "Running code generation..." + $(GO) generate ./... + +verify: fmt-check lint test + @echo "Verification complete" + # Install MkDocs and dependencies docs-install: @echo "Installing MkDocs dependencies..." diff --git a/README.md b/README.md new file mode 100644 index 0000000..1bcd729 --- /dev/null +++ b/README.md @@ -0,0 +1,260 @@ +# Go Platform (goplt) + +**Plugin-friendly SaaS/Enterprise Platform โ€“ Go Edition** + +A modular, extensible platform built with Go that provides a solid foundation for building scalable, secure, and observable applications. The platform supports plugin-based architecture, enabling teams to build feature modules independently while sharing core services. + +## ๐Ÿ—๏ธ Architecture Overview + +Go Platform follows **Clean/Hexagonal Architecture** principles with clear separation between: + +- **Core Kernel**: Foundation services (authentication, authorization, configuration, logging, observability) +- **Module Framework**: Plugin system for extending functionality +- **Infrastructure Adapters**: Support for databases, caching, event buses, and job scheduling +- **Security-by-Design**: Built-in JWT authentication, RBAC/ABAC authorization, and audit logging +- **Observability**: OpenTelemetry integration for tracing, metrics, and logging + +### Key Principles + +- **Clean Architecture**: Separation between `pkg/` (interfaces) and `internal/` (implementations) +- **Dependency Injection**: Using `uber-go/fx` for lifecycle management +- **Microservices-Ready**: Each module is an independent service from day one +- **Plugin-First Design**: Extensible architecture supporting static and dynamic module loading +- **Security-by-Design**: JWT authentication, RBAC/ABAC, and audit logging +- **Observability**: OpenTelemetry, structured logging, and Prometheus metrics + +## ๐Ÿ“ Directory Structure + +``` +goplt/ +โ”œโ”€โ”€ cmd/ +โ”‚ โ””โ”€โ”€ platform/ # Main application entry point +โ”œโ”€โ”€ internal/ # Private implementation code +โ”‚ โ”œโ”€โ”€ di/ # Dependency injection container +โ”‚ โ”œโ”€โ”€ registry/ # Module registry +โ”‚ โ”œโ”€โ”€ pluginloader/ # Plugin loader (optional) +โ”‚ โ”œโ”€โ”€ config/ # Configuration implementation +โ”‚ โ”œโ”€โ”€ logger/ # Logger implementation +โ”‚ โ”œโ”€โ”€ infra/ # Infrastructure adapters +โ”‚ โ””โ”€โ”€ ent/ # Ent ORM schemas +โ”œโ”€โ”€ pkg/ # Public interfaces (exported) +โ”‚ โ”œโ”€โ”€ config/ # ConfigProvider interface +โ”‚ โ”œโ”€โ”€ logger/ # Logger interface +โ”‚ โ”œโ”€โ”€ module/ # IModule interface +โ”‚ โ”œโ”€โ”€ auth/ # Auth interfaces +โ”‚ โ”œโ”€โ”€ perm/ # Permission DSL +โ”‚ โ””โ”€โ”€ infra/ # Infrastructure interfaces +โ”œโ”€โ”€ modules/ # Feature modules +โ”‚ โ””โ”€โ”€ blog/ # Sample Blog module (Epic 4) +โ”œโ”€โ”€ config/ # Configuration files +โ”‚ โ”œโ”€โ”€ default.yaml +โ”‚ โ”œโ”€โ”€ development.yaml +โ”‚ โ””โ”€โ”€ production.yaml +โ”œโ”€โ”€ api/ # OpenAPI specs +โ”œโ”€โ”€ scripts/ # Build/test scripts +โ”œโ”€โ”€ docs/ # Documentation +โ”œโ”€โ”€ ops/ # Operations (Grafana dashboards, etc.) +โ””โ”€โ”€ .github/ + โ””โ”€โ”€ workflows/ + โ””โ”€โ”€ ci.yml +``` + +## ๐Ÿš€ Quick Start + +### Prerequisites + +- **Go 1.24+**: [Install Go](https://golang.org/doc/install) +- **Make**: For using development commands +- **Docker** (optional): For containerized development + +### Installation + +1. **Clone the repository** + ```bash + git clone git.dcentral.systems/toolz/goplt.git + cd goplt + ``` + +2. **Install dependencies** + ```bash + go mod download + ``` + +3. **Build the platform** + ```bash + make build + ``` + +4. **Run the platform** + ```bash + ./bin/platform + # Or + go run cmd/platform/main.go + ``` + +### Configuration + +The platform loads configuration from multiple sources with the following precedence: + +1. Environment variables (highest priority) +2. Environment-specific YAML files (`config/development.yaml`, `config/production.yaml`) +3. Base configuration file (`config/default.yaml`) + +Example environment variables: +```bash +export SERVER_PORT=8080 +export DATABASE_DSN="postgres://user:pass@localhost/dbname" +export LOGGING_LEVEL=debug +``` + +## ๐Ÿ› ๏ธ Development + +### Make Commands + +```bash +make help # Show all available commands +make test # Run all tests +make test-coverage # Run tests with coverage report +make lint # Run linters +make fmt # Format code +make fmt-check # Check code formatting +make build # Build platform binary +make clean # Clean build artifacts +make docker-build # Build Docker image +make docker-run # Run Docker container +make verify # Verify code (fmt, lint, test) +``` + +### Running Tests + +```bash +# Run all tests +make test + +# Run tests with coverage +make test-coverage + +# Run tests for a specific package +go test ./internal/config/... +``` + +### Code Quality + +The project uses: +- **golangci-lint**: For comprehensive linting +- **gofmt**: For code formatting +- **go vet**: For static analysis + +Run all checks: +```bash +make verify +``` + +## ๐Ÿ“š Documentation + +Comprehensive documentation is available in the `docs/` directory: + +- **[Architecture Documentation](docs/content/architecture/)**: System architecture and design patterns +- **[Architecture Decision Records (ADRs)](docs/content/adr/)**: Documented architectural decisions +- **[Implementation Stories](docs/content/stories/)**: Epic-based implementation tasks +- **[Playbook](docs/content/playbook.md)**: Detailed implementation guide and best practices + +### View Documentation Locally + +```bash +# Using MkDocs (requires Python) +make docs-install +make docs-serve + +# Using Docker (no Python required) +make docs-docker +``` + +Documentation will be available at `http://127.0.0.1:8000` + +## ๐Ÿ›๏ธ Architecture + +### Core Kernel + +The platform provides a core kernel with essential services: + +- **Configuration Management**: Hierarchical configuration with YAML and environment variable support +- **Structured Logging**: JSON-formatted logs with request correlation +- **Dependency Injection**: FX-based DI container for service lifecycle management +- **Health & Metrics**: Health check endpoints and Prometheus metrics +- **Error Handling**: Centralized error handling with proper error wrapping + +### Module System + +Modules extend the platform's functionality by implementing the `IModule` interface: + +```go +type IModule interface { + Name() string + Version() string + Initialize(ctx context.Context, app *Application) error + Routes() []Route + Permissions() []Permission +} +``` + +### Security + +- **Authentication**: JWT-based authentication with access and refresh tokens +- **Authorization**: RBAC/ABAC authorization system +- **Audit Logging**: Immutable audit trail for security-relevant actions +- **Rate Limiting**: Configurable rate limiting per endpoint + +### Observability + +- **Distributed Tracing**: OpenTelemetry integration for request tracing +- **Metrics**: Prometheus metrics for monitoring +- **Structured Logging**: JSON-formatted logs with correlation IDs +- **Health Checks**: Kubernetes-ready health and readiness endpoints + +## ๐Ÿ”ง Configuration + +Configuration is managed through YAML files and environment variables. See `config/default.yaml` for the base configuration structure. + +Key configuration sections: + +- **Server**: HTTP server settings (port, host, timeouts) +- **Database**: Database connection settings +- **Logging**: Log level, format, and output destination +- **Authentication**: JWT settings and token configuration + +## ๐Ÿงช Testing + +The project follows table-driven testing patterns and includes: + +- Unit tests for all packages +- Integration tests for service interactions +- Mock generation for interfaces +- Test coverage reporting + +## ๐Ÿค Contributing + +1. Create a feature branch: `git checkout -b feature/my-feature` +2. Make your changes following the project's architecture principles +3. Run tests and linting: `make verify` +4. Commit your changes with clear messages +5. Push to your branch and create a pull request + +## ๐Ÿ“„ License + +[Add license information here] + +## ๐Ÿ”— Links + +- [Architecture Documentation](docs/content/architecture/) +- [ADRs](docs/content/adr/) +- [Implementation Plan](docs/content/plan.md) +- [Playbook](docs/content/playbook.md) + +## ๐Ÿ“ž Support + +For questions and support, please refer to the documentation or create an issue in the repository. + +--- + +**Built with โค๏ธ using Go** diff --git a/cmd/platform/main.go b/cmd/platform/main.go new file mode 100644 index 0000000..3a297e7 --- /dev/null +++ b/cmd/platform/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "context" + "fmt" + "os" + + "go.uber.org/fx" + "git.dcentral.systems/toolz/goplt/internal/di" + "git.dcentral.systems/toolz/goplt/pkg/logger" +) + +func main() { + // Create DI container with lifecycle hooks + container := di.NewContainer( + // Invoke lifecycle hooks + fx.Invoke(di.RegisterLifecycleHooks), + ) + + // Create root context + ctx := context.Background() + + // Start the application + if err := container.Start(ctx); err != nil { + log := logger.GetGlobalLogger() + if log != nil { + log.Error("Failed to start application", + logger.Error(err), + ) + } else { + fmt.Fprintf(os.Stderr, "Failed to start application: %v\n", err) + } + os.Exit(1) + } +} diff --git a/config/default.yaml b/config/default.yaml new file mode 100644 index 0000000..bc263ab --- /dev/null +++ b/config/default.yaml @@ -0,0 +1,18 @@ +environment: development + +server: + port: 8080 + host: "0.0.0.0" + read_timeout: 30s + write_timeout: 30s + +database: + driver: "postgres" + dsn: "" + max_connections: 25 + max_idle_connections: 5 + +logging: + level: "info" + format: "json" + output: "stdout" diff --git a/config/development.yaml b/config/development.yaml new file mode 100644 index 0000000..f661dd1 --- /dev/null +++ b/config/development.yaml @@ -0,0 +1,5 @@ +environment: development + +logging: + level: "debug" + format: "console" diff --git a/config/production.yaml b/config/production.yaml new file mode 100644 index 0000000..242a951 --- /dev/null +++ b/config/production.yaml @@ -0,0 +1,5 @@ +environment: production + +logging: + level: "warn" + format: "json" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cb4a029 --- /dev/null +++ b/go.mod @@ -0,0 +1,51 @@ +module git.dcentral.systems/toolz/goplt + +go 1.24 + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.9.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.18.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/dig v1.19.0 // indirect + go.uber.org/fx v1.24.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.26.0 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.16.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1beca3b --- /dev/null +++ b/go.sum @@ -0,0 +1,120 @@ +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.0 h1:pN6W1ub/G4OfnM+NR9p7xP9R6TltLUzp5JG9yZD3Qg0= +github.com/spf13/viper v1.18.0/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= +go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..41c930f --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,102 @@ +package config + +import ( + "fmt" + "time" + + "github.com/spf13/viper" + "git.dcentral.systems/toolz/goplt/pkg/config" +) + +// viperConfig implements the ConfigProvider interface using Viper. +type viperConfig struct { + v *viper.Viper +} + +// NewViperConfig creates a new Viper-based configuration provider. +func NewViperConfig(v *viper.Viper) config.ConfigProvider { + return &viperConfig{v: v} +} + +// Get retrieves a configuration value by key. +func (vc *viperConfig) Get(key string) any { + return vc.v.Get(key) +} + +// Unmarshal unmarshals the entire configuration into the provided struct. +func (vc *viperConfig) Unmarshal(v any) error { + return vc.v.Unmarshal(v) +} + +// GetString retrieves a string value by key. +func (vc *viperConfig) GetString(key string) string { + return vc.v.GetString(key) +} + +// GetInt retrieves an integer value by key. +func (vc *viperConfig) GetInt(key string) int { + return vc.v.GetInt(key) +} + +// GetBool retrieves a boolean value by key. +func (vc *viperConfig) GetBool(key string) bool { + return vc.v.GetBool(key) +} + +// GetStringSlice retrieves a string slice value by key. +func (vc *viperConfig) GetStringSlice(key string) []string { + return vc.v.GetStringSlice(key) +} + +// GetDuration retrieves a duration value by key. +func (vc *viperConfig) GetDuration(key string) time.Duration { + return vc.v.GetDuration(key) +} + +// IsSet checks if a configuration key is set. +func (vc *viperConfig) IsSet(key string) bool { + return vc.v.IsSet(key) +} + +// LoadConfig loads configuration from files and environment variables. +// It follows this precedence order (highest to lowest): +// 1. Environment variables +// 2. Environment-specific YAML files (development.yaml, production.yaml) +// 3. Default YAML file (default.yaml) +// +// The env parameter determines which environment-specific file to load. +// Supported values: "development", "production", or empty string for default only. +func LoadConfig(env string) (config.ConfigProvider, error) { + v := viper.New() + + // Set default configuration file name + v.SetConfigName("default") + v.SetConfigType("yaml") + v.AddConfigPath("config") + + // Read default configuration + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("failed to read default config: %w", err) + } + + // Load environment-specific configuration if specified + if env != "" { + v.SetConfigName(env) + // Merge environment-specific config (if it exists) + if err := v.MergeInConfig(); err != nil { + // Environment-specific file is optional, so we only warn + // but don't fail if it doesn't exist + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("failed to merge environment config: %w", err) + } + } + } + + // Enable environment variable support + v.AutomaticEnv() + // Environment variables can be set in UPPER_SNAKE_CASE format + // and will automatically map to nested keys (e.g., SERVER_PORT -> server.port) + // Viper handles this automatically with AutomaticEnv() + + return NewViperConfig(v), nil +} diff --git a/internal/di/container.go b/internal/di/container.go new file mode 100644 index 0000000..c0419cf --- /dev/null +++ b/internal/di/container.go @@ -0,0 +1,66 @@ +package di + +import ( + "context" + "os" + "os/signal" + "syscall" + "time" + + "go.uber.org/fx" +) + +// Container wraps the FX application and provides lifecycle management. +type Container struct { + app *fx.App +} + +// NewContainer creates a new DI container with the provided options. +func NewContainer(opts ...fx.Option) *Container { + // Add core module + allOpts := []fx.Option{CoreModule()} + allOpts = append(allOpts, opts...) + + app := fx.New(allOpts...) + + return &Container{ + app: app, + } +} + +// Start starts the container and blocks until shutdown. +func (c *Container) Start(ctx context.Context) error { + // Start the FX app + if err := c.app.Start(ctx); err != nil { + return err + } + + // Wait for interrupt signal + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + // Block until signal received + <-sigChan + + // Create shutdown context + shutdownCtx, cancel := context.WithTimeout(context.Background(), c.getShutdownTimeout()) + defer cancel() + + // Stop the FX app + if err := c.app.Stop(shutdownCtx); err != nil { + return err + } + + return nil +} + +// Stop stops the container gracefully. +func (c *Container) Stop(ctx context.Context) error { + return c.app.Stop(ctx) +} + +// getShutdownTimeout returns the shutdown timeout duration. +// Can be made configurable in the future. +func (c *Container) getShutdownTimeout() time.Duration { + return 30 * time.Second +} diff --git a/internal/di/providers.go b/internal/di/providers.go new file mode 100644 index 0000000..588bb60 --- /dev/null +++ b/internal/di/providers.go @@ -0,0 +1,83 @@ +package di + +import ( + "context" + "fmt" + "os" + + "go.uber.org/fx" + configimpl "git.dcentral.systems/toolz/goplt/internal/config" + loggerimpl "git.dcentral.systems/toolz/goplt/internal/logger" + "git.dcentral.systems/toolz/goplt/pkg/config" + "git.dcentral.systems/toolz/goplt/pkg/logger" +) + +// ProvideConfig creates an FX option that provides ConfigProvider. +func ProvideConfig() fx.Option { + return fx.Provide(func() (config.ConfigProvider, error) { + // Determine environment from environment variable or default to "development" + env := os.Getenv("ENVIRONMENT") + if env == "" { + env = "development" + } + + cfg, err := configimpl.LoadConfig(env) + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + + return cfg, nil + }) +} + +// ProvideLogger creates an FX option that provides Logger. +func ProvideLogger() fx.Option { + return fx.Provide(func(cfg config.ConfigProvider) (logger.Logger, error) { + level := cfg.GetString("logging.level") + if level == "" { + level = "info" + } + + format := cfg.GetString("logging.format") + if format == "" { + format = "json" + } + + log, err := loggerimpl.NewZapLogger(level, format) + if err != nil { + return nil, fmt.Errorf("failed to create logger: %w", err) + } + + // Set as global logger + logger.SetGlobalLogger(log) + + return log, nil + }) +} + +// CoreModule returns an FX option that provides all core services. +// This includes configuration and logging. +func CoreModule() fx.Option { + return fx.Options( + ProvideConfig(), + ProvideLogger(), + ) +} + +// RegisterLifecycleHooks registers lifecycle hooks for logging. +func RegisterLifecycleHooks(lc fx.Lifecycle, l logger.Logger) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + l.Info("Application starting", + logger.String("component", "bootstrap"), + ) + return nil + }, + OnStop: func(ctx context.Context) error { + l.Info("Application shutting down", + logger.String("component", "bootstrap"), + ) + return nil + }, + }) +} diff --git a/internal/logger/middleware.go b/internal/logger/middleware.go new file mode 100644 index 0000000..3a342ed --- /dev/null +++ b/internal/logger/middleware.go @@ -0,0 +1,92 @@ +package logger + +import ( + "context" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "git.dcentral.systems/toolz/goplt/pkg/logger" +) + +const ( + // RequestIDHeader is the HTTP header name for request ID. + RequestIDHeader = "X-Request-ID" +) + +// RequestIDMiddleware creates a Gin middleware that: +// 1. Generates a unique request ID for each request (or uses existing one from header) +// 2. Adds the request ID to the request context +// 3. Adds the request ID to the response headers +// 4. Makes the request ID available for logging +func RequestIDMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // Check if request ID already exists in header + requestID := c.GetHeader(RequestIDHeader) + + // Generate new request ID if not present + if requestID == "" { + requestID = uuid.New().String() + } + + // Add request ID to context + ctx := context.WithValue(c.Request.Context(), RequestIDKey(), requestID) + c.Request = c.Request.WithContext(ctx) + + // Add request ID to response header + c.Header(RequestIDHeader, requestID) + + // Continue processing + c.Next() + } +} + +// RequestIDFromContext extracts the request ID from the context. +func RequestIDFromContext(ctx context.Context) string { + if requestID, ok := ctx.Value(RequestIDKey()).(string); ok { + return requestID + } + return "" +} + +// SetRequestID sets the request ID in the context. +func SetRequestID(ctx context.Context, requestID string) context.Context { + return context.WithValue(ctx, RequestIDKey(), requestID) +} + +// SetUserID sets the user ID in the context. +func SetUserID(ctx context.Context, userID string) context.Context { + return context.WithValue(ctx, UserIDKey(), userID) +} + +// UserIDFromContext extracts the user ID from the context. +func UserIDFromContext(ctx context.Context) string { + if userID, ok := ctx.Value(UserIDKey()).(string); ok { + return userID + } + return "" +} + +// LoggingMiddleware creates a Gin middleware that logs HTTP requests. +// It uses the logger from the context and includes request ID. +func LoggingMiddleware(l logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + // Get logger with context + log := l.WithContext(c.Request.Context()) + + // Log request + log.Info("HTTP request", + logger.String("method", c.Request.Method), + logger.String("path", c.Request.URL.Path), + logger.String("remote_addr", c.ClientIP()), + ) + + // Process request + c.Next() + + // Log response + log.Info("HTTP response", + logger.Int("status", c.Writer.Status()), + logger.Int("size", c.Writer.Size()), + ) + } +} diff --git a/internal/logger/zap_logger.go b/internal/logger/zap_logger.go new file mode 100644 index 0000000..0c2a4d7 --- /dev/null +++ b/internal/logger/zap_logger.go @@ -0,0 +1,133 @@ +package logger + +import ( + "context" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "git.dcentral.systems/toolz/goplt/pkg/logger" +) + +const ( + // Context keys for extracting values from context + requestIDKey = "request_id" + userIDKey = "user_id" +) + +// zapLogger implements the Logger interface using zap. +type zapLogger struct { + zap *zap.Logger +} + +// NewZapLogger creates a new zap-based logger. +// The format parameter determines the output format: +// - "json": JSON format (production) +// - "console": Human-readable format (development) +func NewZapLogger(level string, format string) (logger.Logger, error) { + var zapConfig zap.Config + var zapLevel zapcore.Level + + // Parse log level + if err := zapLevel.UnmarshalText([]byte(level)); err != nil { + zapLevel = zapcore.InfoLevel + } + + // Configure based on format + if format == "json" { + zapConfig = zap.NewProductionConfig() + } else { + zapConfig = zap.NewDevelopmentConfig() + } + + zapConfig.Level = zap.NewAtomicLevelAt(zapLevel) + zapConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + + z, err := zapConfig.Build() + if err != nil { + return nil, err + } + + return &zapLogger{zap: z}, nil +} + +// Debug logs a message at debug level. +func (zl *zapLogger) Debug(msg string, fields ...logger.Field) { + zl.zap.Debug(msg, convertFields(fields)...) +} + +// Info logs a message at info level. +func (zl *zapLogger) Info(msg string, fields ...logger.Field) { + zl.zap.Info(msg, convertFields(fields)...) +} + +// Warn logs a message at warning level. +func (zl *zapLogger) Warn(msg string, fields ...logger.Field) { + zl.zap.Warn(msg, convertFields(fields)...) +} + +// Error logs a message at error level. +func (zl *zapLogger) Error(msg string, fields ...logger.Field) { + zl.zap.Error(msg, convertFields(fields)...) +} + +// With creates a child logger with the specified fields. +func (zl *zapLogger) With(fields ...logger.Field) logger.Logger { + return &zapLogger{ + zap: zl.zap.With(convertFields(fields)...), + } +} + +// WithContext creates a child logger with fields extracted from context. +func (zl *zapLogger) WithContext(ctx context.Context) logger.Logger { + fields := make([]logger.Field, 0) + + // Extract request ID from context + if requestID, ok := ctx.Value(requestIDKey).(string); ok && requestID != "" { + fields = append(fields, zap.String("request_id", requestID)) + } + + // Extract user ID from context + if userID, ok := ctx.Value(userIDKey).(string); ok && userID != "" { + fields = append(fields, zap.String("user_id", userID)) + } + + if len(fields) == 0 { + return zl + } + + return &zapLogger{ + zap: zl.zap.With(convertFields(fields)...), + } +} + +// convertFields converts logger.Field to zap.Field. +// Since Field is an alias for zap.Field, we can cast directly. +func convertFields(fields []logger.Field) []zap.Field { + if len(fields) == 0 { + return nil + } + + zapFields := make([]zap.Field, 0, len(fields)) + for _, f := range fields { + // Type assert to zap.Field + if zf, ok := f.(zap.Field); ok { + zapFields = append(zapFields, zf) + } else { + // Fallback: convert to Any field + zapFields = append(zapFields, zap.Any("field", f)) + } + } + return zapFields +} + +// RequestIDKey returns the context key for request ID. +// This is exported so modules can use it to set request IDs in context. +func RequestIDKey() string { + return requestIDKey +} + +// UserIDKey returns the context key for user ID. +// This is exported so modules can use it to set user IDs in context. +func UserIDKey() string { + return userIDKey +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..2e5aa1b --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,39 @@ +package config + +import "time" + +// ConfigProvider defines the interface for configuration management. +// It provides type-safe access to configuration values from various sources +// (YAML files, environment variables, etc.). +type ConfigProvider interface { + // Get retrieves a configuration value by key. + // Returns nil if the key is not found. + Get(key string) any + + // Unmarshal unmarshals the entire configuration into the provided struct. + // The struct should use appropriate tags for mapping configuration keys. + Unmarshal(v any) error + + // GetString retrieves a string value by key. + // Returns empty string if the key is not found. + GetString(key string) string + + // GetInt retrieves an integer value by key. + // Returns 0 if the key is not found or cannot be converted. + GetInt(key string) int + + // GetBool retrieves a boolean value by key. + // Returns false if the key is not found or cannot be converted. + GetBool(key string) bool + + // GetStringSlice retrieves a string slice value by key. + // Returns nil if the key is not found. + GetStringSlice(key string) []string + + // GetDuration retrieves a duration value by key. + // Returns 0 if the key is not found or cannot be parsed. + GetDuration(key string) time.Duration + + // IsSet checks if a configuration key is set. + IsSet(key string) bool +} diff --git a/pkg/logger/fields.go b/pkg/logger/fields.go new file mode 100644 index 0000000..23c523e --- /dev/null +++ b/pkg/logger/fields.go @@ -0,0 +1,33 @@ +package logger + +import "go.uber.org/zap" + +// String creates a string field for structured logging. +func String(key, value string) Field { + return zap.String(key, value) +} + +// Int creates an integer field for structured logging. +func Int(key string, value int) Field { + return zap.Int(key, value) +} + +// Int64 creates an int64 field for structured logging. +func Int64(key string, value int64) Field { + return zap.Int64(key, value) +} + +// Bool creates a boolean field for structured logging. +func Bool(key string, value bool) Field { + return zap.Bool(key, value) +} + +// Error creates an error field for structured logging. +func Error(err error) Field { + return zap.Error(err) +} + +// Any creates a field with any value type. +func Any(key string, value any) Field { + return zap.Any(key, value) +} diff --git a/pkg/logger/global.go b/pkg/logger/global.go new file mode 100644 index 0000000..18b611b --- /dev/null +++ b/pkg/logger/global.go @@ -0,0 +1,60 @@ +package logger + +import ( + "context" + "sync" +) + +var ( + globalLogger Logger + globalMu sync.RWMutex +) + +// SetGlobalLogger sets the global logger instance. +func SetGlobalLogger(l Logger) { + globalMu.Lock() + defer globalMu.Unlock() + globalLogger = l +} + +// GetGlobalLogger returns the global logger instance. +// Returns a no-op logger if no global logger is set. +func GetGlobalLogger() Logger { + globalMu.RLock() + defer globalMu.RUnlock() + if globalLogger == nil { + return &noOpLogger{} + } + return globalLogger +} + +// Debug logs a message at debug level using the global logger. +func Debug(msg string, fields ...Field) { + GetGlobalLogger().Debug(msg, fields...) +} + +// Info logs a message at info level using the global logger. +func Info(msg string, fields ...Field) { + GetGlobalLogger().Info(msg, fields...) +} + +// Warn logs a message at warning level using the global logger. +func Warn(msg string, fields ...Field) { + GetGlobalLogger().Warn(msg, fields...) +} + +// ErrorLog logs a message at error level using the global logger. +func ErrorLog(msg string, fields ...Field) { + GetGlobalLogger().Error(msg, fields...) +} + +// noOpLogger is a logger that does nothing. +// Used as a fallback when no global logger is set. +type noOpLogger struct{} + +func (n *noOpLogger) Debug(msg string, fields ...Field) {} +func (n *noOpLogger) Info(msg string, fields ...Field) {} +func (n *noOpLogger) Warn(msg string, fields ...Field) {} +func (n *noOpLogger) Error(msg string, fields ...Field) {} +func (n *noOpLogger) With(fields ...Field) Logger { return n } +func (n *noOpLogger) WithContext(ctx context.Context) Logger { return n } diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..7fd91c5 --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,33 @@ +package logger + +import ( + "context" +) + +// Field represents a key-value pair for structured logging. +// This is an alias for zap.Field to maintain compatibility with zap. +type Field = interface{} + +// Logger defines the interface for structured logging. +// It provides methods for logging at different levels with structured fields. +type Logger interface { + // Debug logs a message at debug level with optional fields. + Debug(msg string, fields ...Field) + + // Info logs a message at info level with optional fields. + Info(msg string, fields ...Field) + + // Warn logs a message at warning level with optional fields. + Warn(msg string, fields ...Field) + + // Error logs a message at error level with optional fields. + Error(msg string, fields ...Field) + + // With creates a child logger with the specified fields. + // All subsequent log calls will include these fields. + With(fields ...Field) Logger + + // WithContext creates a child logger with fields extracted from context. + // Typically extracts request ID, user ID, and other context values. + WithContext(ctx context.Context) Logger +} diff --git a/platform b/platform new file mode 100755 index 0000000..df29800 Binary files /dev/null and b/platform differ