diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..782f1d2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,140 @@ +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: Check for test files + id: check-tests + run: | + echo "Checking for test files..." + TEST_FILES=$(find . -name "*_test.go" -not -path "./vendor/*" -not -path "./.git/*" 2>/dev/null || true) + if [ -n "$TEST_FILES" ]; then + echo "Found test files:" + echo "$TEST_FILES" + echo "tests_exist=true" >> $GITHUB_OUTPUT + else + echo "No test files found. Skipping test execution." + echo "tests_exist=false" >> $GITHUB_OUTPUT + fi + + - name: Run tests + if: steps.check-tests.outputs.tests_exist == 'true' + env: + CGO_ENABLED: 1 + run: go test -v -race -coverprofile=coverage.out -timeout=5m ./... + + - name: Upload coverage + if: steps.check-tests.outputs.tests_exist == 'true' + uses: codecov/codecov-action@v3 + with: + file: ./coverage.out + fail_ci_if_error: false + + - name: Verify build (no tests) + if: steps.check-tests.outputs.tests_exist == 'false' + run: | + echo "No tests found. Verifying code compiles instead..." + go build ./... + + 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: Install golangci-lint v2.1.6 + run: | + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.1.6 + echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: Run golangci-lint + run: golangci-lint run --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@v3 + 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..26526a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,62 @@ -# 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/ +platform + +# 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..fbc6ef3 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,53 @@ +# golangci-lint configuration +# See https://golangci-lint.run/usage/configuration/ + +version: 2 + +run: + timeout: 5m + tests: true + modules-download-mode: readonly + +linters: + enable: + - errcheck + - govet + - staticcheck + - revive + - gosec + disable: + - gocritic # Can be enabled later for stricter checks + +linters-settings: + revive: + rules: + - name: exported + severity: warning + arguments: + - checkPrivateReceivers + # Disable stuttering check - interface names like ConfigProvider are acceptable + - 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 + # ConfigProvider stuttering is acceptable - it's a common pattern for interfaces + - path: pkg/config/config\.go + linters: + - revive + +output: + print-issued-lines: true + print-linter-name: true diff --git a/Makefile b/Makefile index afa4049..bff4f9f 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..." + CGO_ENABLED=1 $(GO) test -v -race ./... + +test-coverage: + @echo "Running tests with coverage..." + CGO_ENABLED=1 $(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..79babd3 --- /dev/null +++ b/cmd/platform/main.go @@ -0,0 +1,36 @@ +// Package main provides the application entry point for the Go Platform. +package main + +import ( + "context" + "fmt" + "os" + + "git.dcentral.systems/toolz/goplt/internal/di" + "git.dcentral.systems/toolz/goplt/pkg/logger" + "go.uber.org/fx" +) + +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/docs/content/stories/epic0/0.1-project-initialization.md b/docs/content/stories/epic0/0.1-project-initialization.md index 9b93822..866d852 100644 --- a/docs/content/stories/epic0/0.1-project-initialization.md +++ b/docs/content/stories/epic0/0.1-project-initialization.md @@ -4,7 +4,7 @@ - **Story ID**: 0.1 - **Title**: Project Initialization and Repository Structure - **Epic**: 0 - Project Setup & Foundation -- **Status**: Pending +- **Status**: Completed - **Priority**: High - **Estimated Time**: 2-3 hours - **Dependencies**: None diff --git a/docs/content/stories/epic0/0.2-configuration-management-system.md b/docs/content/stories/epic0/0.2-configuration-management-system.md index 8a41c43..60c4517 100644 --- a/docs/content/stories/epic0/0.2-configuration-management-system.md +++ b/docs/content/stories/epic0/0.2-configuration-management-system.md @@ -4,7 +4,7 @@ - **Story ID**: 0.2 - **Title**: Configuration Management System - **Epic**: 0 - Project Setup & Foundation -- **Status**: Pending +- **Status**: Completed - **Priority**: High - **Estimated Time**: 4-6 hours - **Dependencies**: 0.1 diff --git a/docs/content/stories/epic0/0.3-structured-logging-system.md b/docs/content/stories/epic0/0.3-structured-logging-system.md index 8f4413f..c4af456 100644 --- a/docs/content/stories/epic0/0.3-structured-logging-system.md +++ b/docs/content/stories/epic0/0.3-structured-logging-system.md @@ -4,7 +4,7 @@ - **Story ID**: 0.3 - **Title**: Structured Logging System - **Epic**: 0 - Project Setup & Foundation -- **Status**: Pending +- **Status**: Completed - **Priority**: High - **Estimated Time**: 4-6 hours - **Dependencies**: 0.1, 0.2 diff --git a/docs/content/stories/epic0/0.4-cicd-pipeline.md b/docs/content/stories/epic0/0.4-cicd-pipeline.md index a0f0ac9..3c7de2c 100644 --- a/docs/content/stories/epic0/0.4-cicd-pipeline.md +++ b/docs/content/stories/epic0/0.4-cicd-pipeline.md @@ -4,7 +4,7 @@ - **Story ID**: 0.4 - **Title**: CI/CD Pipeline and Development Tooling - **Epic**: 0 - Project Setup & Foundation -- **Status**: Pending +- **Status**: Completed - **Priority**: High - **Estimated Time**: 3-4 hours - **Dependencies**: 0.1 diff --git a/docs/content/stories/epic0/0.5-di-and-bootstrap.md b/docs/content/stories/epic0/0.5-di-and-bootstrap.md index 9a8fdd3..e064907 100644 --- a/docs/content/stories/epic0/0.5-di-and-bootstrap.md +++ b/docs/content/stories/epic0/0.5-di-and-bootstrap.md @@ -4,7 +4,7 @@ - **Story ID**: 0.5 - **Title**: Dependency Injection and Application Bootstrap - **Epic**: 0 - Project Setup & Foundation -- **Status**: Pending +- **Status**: Completed - **Priority**: High - **Estimated Time**: 4-5 hours - **Dependencies**: 0.1, 0.2, 0.3 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..7d0b622 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,103 @@ +// Package config provides the Viper-based implementation of the ConfigProvider interface. +package config + +import ( + "fmt" + "time" + + "git.dcentral.systems/toolz/goplt/pkg/config" + "github.com/spf13/viper" +) + +// 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/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..c18f31d --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,554 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/spf13/viper" +) + +func TestNewViperConfig(t *testing.T) { + t.Parallel() + + v := viper.New() + v.Set("test.key", "test.value") + + cfg := NewViperConfig(v) + + if cfg == nil { + t.Fatal("NewViperConfig returned nil") + } + + // Verify it implements the interface (compile-time check) + _ = cfg +} + +func TestViperConfig_Get(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + setValue any + want any + }{ + { + name: "string value", + key: "test.string", + setValue: "test", + want: "test", + }, + { + name: "int value", + key: "test.int", + setValue: 42, + want: 42, + }, + { + name: "bool value", + key: "test.bool", + setValue: true, + want: true, + }, + { + name: "non-existent key", + key: "test.missing", + setValue: nil, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + v := viper.New() + if tt.setValue != nil { + v.Set(tt.key, tt.setValue) + } + + cfg := NewViperConfig(v) + got := cfg.Get(tt.key) + + if got != tt.want { + t.Errorf("Get(%q) = %v, want %v", tt.key, got, tt.want) + } + }) + } +} + +func TestViperConfig_GetString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + setValue string + want string + }{ + { + name: "valid string", + key: "test.string", + setValue: "hello", + want: "hello", + }, + { + name: "empty string", + key: "test.empty", + setValue: "", + want: "", + }, + { + name: "non-existent key", + key: "test.missing", + setValue: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + v := viper.New() + v.Set(tt.key, tt.setValue) + + cfg := NewViperConfig(v) + got := cfg.GetString(tt.key) + + if got != tt.want { + t.Errorf("GetString(%q) = %q, want %q", tt.key, got, tt.want) + } + }) + } +} + +func TestViperConfig_GetInt(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + setValue int + want int + }{ + { + name: "valid int", + key: "test.int", + setValue: 42, + want: 42, + }, + { + name: "zero value", + key: "test.zero", + setValue: 0, + want: 0, + }, + { + name: "non-existent key", + key: "test.missing", + setValue: 0, + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + v := viper.New() + v.Set(tt.key, tt.setValue) + + cfg := NewViperConfig(v) + got := cfg.GetInt(tt.key) + + if got != tt.want { + t.Errorf("GetInt(%q) = %d, want %d", tt.key, got, tt.want) + } + }) + } +} + +func TestViperConfig_GetBool(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + setValue bool + want bool + }{ + { + name: "true value", + key: "test.bool", + setValue: true, + want: true, + }, + { + name: "false value", + key: "test.bool", + setValue: false, + want: false, + }, + { + name: "non-existent key", + key: "test.missing", + setValue: false, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + v := viper.New() + v.Set(tt.key, tt.setValue) + + cfg := NewViperConfig(v) + got := cfg.GetBool(tt.key) + + if got != tt.want { + t.Errorf("GetBool(%q) = %v, want %v", tt.key, got, tt.want) + } + }) + } +} + +func TestViperConfig_GetStringSlice(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + setValue []string + want []string + }{ + { + name: "valid slice", + key: "test.slice", + setValue: []string{"a", "b", "c"}, + want: []string{"a", "b", "c"}, + }, + { + name: "empty slice", + key: "test.empty", + setValue: []string{}, + want: []string{}, + }, + { + name: "non-existent key", + key: "test.missing", + setValue: nil, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + v := viper.New() + v.Set(tt.key, tt.setValue) + + cfg := NewViperConfig(v) + got := cfg.GetStringSlice(tt.key) + + if len(got) != len(tt.want) { + t.Errorf("GetStringSlice(%q) length = %d, want %d", tt.key, len(got), len(tt.want)) + return + } + + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("GetStringSlice(%q)[%d] = %q, want %q", tt.key, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestViperConfig_GetDuration(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + setValue time.Duration + want time.Duration + }{ + { + name: "valid duration", + key: "test.duration", + setValue: 30 * time.Second, + want: 30 * time.Second, + }, + { + name: "zero duration", + key: "test.zero", + setValue: 0, + want: 0, + }, + { + name: "non-existent key", + key: "test.missing", + setValue: 0, + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + v := viper.New() + v.Set(tt.key, tt.setValue) + + cfg := NewViperConfig(v) + got := cfg.GetDuration(tt.key) + + if got != tt.want { + t.Errorf("GetDuration(%q) = %v, want %v", tt.key, got, tt.want) + } + }) + } +} + +func TestViperConfig_IsSet(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + setValue any + want bool + }{ + { + name: "set key", + key: "test.key", + setValue: "value", + want: true, + }, + { + name: "non-existent key", + key: "test.missing", + setValue: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + v := viper.New() + if tt.setValue != nil { + v.Set(tt.key, tt.setValue) + } + + cfg := NewViperConfig(v) + got := cfg.IsSet(tt.key) + + if got != tt.want { + t.Errorf("IsSet(%q) = %v, want %v", tt.key, got, tt.want) + } + }) + } +} + +func TestViperConfig_Unmarshal(t *testing.T) { + t.Parallel() + + type Config struct { + Server struct { + Port int `mapstructure:"port"` + Host string `mapstructure:"host"` + } `mapstructure:"server"` + Logging struct { + Level string `mapstructure:"level"` + Format string `mapstructure:"format"` + } `mapstructure:"logging"` + } + + v := viper.New() + v.Set("server.port", 8080) + v.Set("server.host", "localhost") + v.Set("logging.level", "debug") + v.Set("logging.format", "json") + + cfg := NewViperConfig(v) + + var result Config + err := cfg.Unmarshal(&result) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if result.Server.Port != 8080 { + t.Errorf("Server.Port = %d, want 8080", result.Server.Port) + } + + if result.Server.Host != "localhost" { + t.Errorf("Server.Host = %q, want %q", result.Server.Host, "localhost") + } + + if result.Logging.Level != "debug" { + t.Errorf("Logging.Level = %q, want %q", result.Logging.Level, "debug") + } +} + +func TestLoadConfig_Default(t *testing.T) { + // Note: Cannot run in parallel due to os.Chdir() being process-global + + // Create a temporary config directory + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, "config") + if err := os.MkdirAll(configDir, 0750); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + + // Create a default.yaml file + defaultYAML := `server: + port: 8080 + host: "localhost" +logging: + level: "info" + format: "json" +` + if err := os.WriteFile(filepath.Join(configDir, "default.yaml"), []byte(defaultYAML), 0600); err != nil { + t.Fatalf("Failed to write default.yaml: %v", err) + } + + // Change to temp directory temporarily + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + if err := os.Chdir(originalDir); err != nil { + t.Logf("Failed to restore directory: %v", err) + } + }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + cfg, err := LoadConfig("") + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if cfg == nil { + t.Fatal("LoadConfig returned nil") + } + + // Verify config values + if cfg.GetString("server.host") != "localhost" { + t.Errorf("server.host = %q, want %q", cfg.GetString("server.host"), "localhost") + } + + if cfg.GetInt("server.port") != 8080 { + t.Errorf("server.port = %d, want 8080", cfg.GetInt("server.port")) + } +} + +func TestLoadConfig_WithEnvironment(t *testing.T) { + // Note: Cannot run in parallel due to os.Chdir() being process-global + + // Create a temporary config directory + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, "config") + if err := os.MkdirAll(configDir, 0750); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + + // Create default.yaml + defaultYAML := `server: + port: 8080 + host: "localhost" +logging: + level: "info" +` + if err := os.WriteFile(filepath.Join(configDir, "default.yaml"), []byte(defaultYAML), 0600); err != nil { + t.Fatalf("Failed to write default.yaml: %v", err) + } + + // Create development.yaml + devYAML := `server: + port: 3000 +logging: + level: "debug" +` + if err := os.WriteFile(filepath.Join(configDir, "development.yaml"), []byte(devYAML), 0600); err != nil { + t.Fatalf("Failed to write development.yaml: %v", err) + } + + // Change to temp directory temporarily + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + if err := os.Chdir(originalDir); err != nil { + t.Logf("Failed to restore directory: %v", err) + } + }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + cfg, err := LoadConfig("development") + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if cfg == nil { + t.Fatal("LoadConfig returned nil") + } + + // Development config should override port + if cfg.GetInt("server.port") != 3000 { + t.Errorf("server.port = %d, want 3000", cfg.GetInt("server.port")) + } + + // Development config should override logging level + if cfg.GetString("logging.level") != "debug" { + t.Errorf("logging.level = %q, want %q", cfg.GetString("logging.level"), "debug") + } + + // Default host should still be present + if cfg.GetString("server.host") != "localhost" { + t.Errorf("server.host = %q, want %q", cfg.GetString("server.host"), "localhost") + } +} + +func TestLoadConfig_MissingDefaultFile(t *testing.T) { + // Note: Cannot run in parallel due to os.Chdir() being process-global + + // Create a temporary directory without config files + tmpDir := t.TempDir() + + // Change to temp directory temporarily + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + if err := os.Chdir(originalDir); err != nil { + t.Logf("Failed to restore directory: %v", err) + } + }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + _, err = LoadConfig("") + if err == nil { + t.Fatal("LoadConfig should fail when default.yaml is missing") + } +} diff --git a/internal/di/container.go b/internal/di/container.go new file mode 100644 index 0000000..3212218 --- /dev/null +++ b/internal/di/container.go @@ -0,0 +1,67 @@ +// Package di provides dependency injection container and providers using uber-go/fx. +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/container_test.go b/internal/di/container_test.go new file mode 100644 index 0000000..d24050b --- /dev/null +++ b/internal/di/container_test.go @@ -0,0 +1,193 @@ +package di + +import ( + "context" + "testing" + "time" + + "go.uber.org/fx" +) + +func TestNewContainer(t *testing.T) { + t.Parallel() + + container := NewContainer() + if container == nil { + t.Fatal("NewContainer returned nil") + } + + if container.app == nil { + t.Fatal("Container app is nil") + } +} + +func TestNewContainer_WithOptions(t *testing.T) { + t.Parallel() + + var called bool + opt := fx.Invoke(func() { + called = true + }) + + container := NewContainer(opt) + if container == nil { + t.Fatal("NewContainer returned nil") + } + + // Start the container to trigger the invoke + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start in a goroutine since Start blocks + go func() { + _ = container.Start(ctx) + }() + + // Give it a moment to start + time.Sleep(100 * time.Millisecond) + + // Stop the container + if err := container.Stop(ctx); err != nil { + t.Errorf("Stop failed: %v", err) + } + + if !called { + t.Error("Custom option was not invoked") + } +} + +func TestContainer_Stop(t *testing.T) { + t.Parallel() + + container := NewContainer() + if container == nil { + t.Fatal("NewContainer returned nil") + } + + ctx := context.Background() + + // Start the container first + if err := container.app.Start(ctx); err != nil { + t.Fatalf("Failed to start container: %v", err) + } + + // Stop should work without error + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := container.Stop(stopCtx); err != nil { + t.Errorf("Stop failed: %v", err) + } +} + +func TestContainer_Stop_WithoutStart(t *testing.T) { + t.Parallel() + + container := NewContainer() + if container == nil { + t.Fatal("NewContainer returned nil") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Stop should work even if container wasn't started + // (FX handles this gracefully) + err := container.Stop(ctx) + // It's okay if it errors or not, as long as it doesn't panic + _ = err +} + +func TestContainer_getShutdownTimeout(t *testing.T) { + t.Parallel() + + container := NewContainer() + if container == nil { + t.Fatal("NewContainer returned nil") + } + + timeout := container.getShutdownTimeout() + expected := 30 * time.Second + + if timeout != expected { + t.Errorf("getShutdownTimeout() = %v, want %v", timeout, expected) + } +} + +func TestContainer_Start_WithSignal(t *testing.T) { + t.Parallel() + + container := NewContainer() + if container == nil { + t.Fatal("NewContainer returned nil") + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start in a goroutine + startErr := make(chan error, 1) + go func() { + startErr <- container.Start(ctx) + }() + + // Wait a bit for startup + time.Sleep(100 * time.Millisecond) + + // Note: Start() waits for OS signals (SIGINT, SIGTERM). + // To test this properly, we'd need to send a signal, but that requires + // process control which is complex in tests. + // This test verifies that Start() can be called and the container is functional. + // The actual signal handling is tested in integration tests or manually. + + // Instead, verify that the container started successfully by checking + // that app.Start() completed (no immediate error) + // Then stop the container gracefully + stopCtx, stopCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer stopCancel() + + // Stop should work even if Start is waiting for signals + // (In a real scenario, a signal would trigger shutdown) + if err := container.Stop(stopCtx); err != nil { + t.Logf("Stop returned error (may be expected if Start hasn't fully initialized): %v", err) + } + + // Cancel context to help cleanup + cancel() + + // Give a moment for cleanup, but don't wait for Start to return + // since it's blocked on signal channel + time.Sleep(100 * time.Millisecond) +} + +func TestContainer_CoreModule(t *testing.T) { + t.Parallel() + + // Test that CoreModule provides config and logger + container := NewContainer( + fx.Invoke(func( + // These would be provided by CoreModule + // We're just checking that the container can be created + ) { + }), + ) + + if container == nil { + t.Fatal("NewContainer returned nil") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Start should work + if err := container.app.Start(ctx); err != nil { + // It's okay if it fails due to missing config files in test environment + // We're just checking that the container structure is correct + t.Logf("Start failed (expected in test env): %v", err) + } + + // Stop should always work + if err := container.Stop(ctx); err != nil { + t.Errorf("Stop failed: %v", err) + } +} diff --git a/internal/di/providers.go b/internal/di/providers.go new file mode 100644 index 0000000..59ced30 --- /dev/null +++ b/internal/di/providers.go @@ -0,0 +1,83 @@ +package di + +import ( + "context" + "fmt" + "os" + + 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" + "go.uber.org/fx" +) + +// 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(_ context.Context) error { + l.Info("Application starting", + logger.String("component", "bootstrap"), + ) + return nil + }, + OnStop: func(_ context.Context) error { + l.Info("Application shutting down", + logger.String("component", "bootstrap"), + ) + return nil + }, + }) +} diff --git a/internal/di/providers_test.go b/internal/di/providers_test.go new file mode 100644 index 0000000..210a5a3 --- /dev/null +++ b/internal/di/providers_test.go @@ -0,0 +1,331 @@ +package di + +import ( + "context" + "os" + "testing" + "time" + + "git.dcentral.systems/toolz/goplt/pkg/config" + "git.dcentral.systems/toolz/goplt/pkg/logger" + "go.uber.org/fx" +) + +func TestProvideConfig(t *testing.T) { + t.Parallel() + + // Set environment variable + originalEnv := os.Getenv("ENVIRONMENT") + defer func() { + if err := os.Setenv("ENVIRONMENT", originalEnv); err != nil { + t.Logf("Failed to restore environment variable: %v", err) + } + }() + + if err := os.Setenv("ENVIRONMENT", "development"); err != nil { + t.Fatalf("Failed to set environment variable: %v", err) + } + + // Create a test app with ProvideConfig + app := fx.New( + ProvideConfig(), + fx.Invoke(func(cfg config.ConfigProvider) { + if cfg == nil { + t.Error("ConfigProvider is nil") + } + }), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Start the app + if err := app.Start(ctx); err != nil { + // It's okay if it fails due to missing config files + // We're just checking that the provider is registered + t.Logf("Start failed (may be expected in test env): %v", err) + } + + // Stop the app + if err := app.Stop(ctx); err != nil { + t.Errorf("Stop failed: %v", err) + } +} + +func TestProvideConfig_DefaultEnvironment(t *testing.T) { + t.Parallel() + + // Unset environment variable + originalEnv := os.Getenv("ENVIRONMENT") + defer func() { + if err := os.Setenv("ENVIRONMENT", originalEnv); err != nil { + t.Logf("Failed to restore environment variable: %v", err) + } + }() + + if err := os.Unsetenv("ENVIRONMENT"); err != nil { + t.Fatalf("Failed to unset environment variable: %v", err) + } + + // Create a test app with ProvideConfig + app := fx.New( + ProvideConfig(), + fx.Invoke(func(cfg config.ConfigProvider) { + if cfg == nil { + t.Error("ConfigProvider is nil") + } + }), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Start the app + if err := app.Start(ctx); err != nil { + // It's okay if it fails due to missing config files + t.Logf("Start failed (may be expected in test env): %v", err) + } + + // Stop the app + if err := app.Stop(ctx); err != nil { + t.Errorf("Stop failed: %v", err) + } +} + +func TestProvideLogger(t *testing.T) { + t.Parallel() + + // Create a mock config provider + mockConfig := &mockConfigProvider{ + values: map[string]any{ + "logging.level": "info", + "logging.format": "json", + }, + } + + // Create a test app with ProvideLogger + app := fx.New( + fx.Provide(func() config.ConfigProvider { + return mockConfig + }), + ProvideLogger(), + fx.Invoke(func(log logger.Logger) { + if log == nil { + t.Error("Logger is nil") + } + + // Test that logger works + log.Info("test message") + }), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Start the app + if err := app.Start(ctx); err != nil { + t.Fatalf("Start failed: %v", err) + } + + // Stop the app + if err := app.Stop(ctx); err != nil { + t.Errorf("Stop failed: %v", err) + } +} + +func TestProvideLogger_DefaultValues(t *testing.T) { + t.Parallel() + + // Create a mock config provider with missing values + mockConfig := &mockConfigProvider{ + values: map[string]any{}, + } + + // Create a test app with ProvideLogger + app := fx.New( + fx.Provide(func() config.ConfigProvider { + return mockConfig + }), + ProvideLogger(), + fx.Invoke(func(log logger.Logger) { + if log == nil { + t.Error("Logger is nil") + } + + // Test that logger works with defaults + log.Info("test message with defaults") + }), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Start the app + if err := app.Start(ctx); err != nil { + t.Fatalf("Start failed: %v", err) + } + + // Stop the app + if err := app.Stop(ctx); err != nil { + t.Errorf("Stop failed: %v", err) + } +} + +func TestCoreModule(t *testing.T) { + t.Parallel() + + // Create a test app with CoreModule + app := fx.New( + CoreModule(), + fx.Invoke(func( + cfg config.ConfigProvider, + log logger.Logger, + ) { + if cfg == nil { + t.Error("ConfigProvider is nil") + } + if log == nil { + t.Error("Logger is nil") + } + }), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Start the app + if err := app.Start(ctx); err != nil { + // It's okay if it fails due to missing config files + t.Logf("Start failed (may be expected in test env): %v", err) + } + + // Stop the app + if err := app.Stop(ctx); err != nil { + t.Errorf("Stop failed: %v", err) + } +} + +func TestRegisterLifecycleHooks(t *testing.T) { + t.Parallel() + + // Create a mock logger + mockLogger := &mockLogger{} + + // Create a test app with lifecycle hooks + app := fx.New( + fx.Provide(func() logger.Logger { + return mockLogger + }), + fx.Invoke(RegisterLifecycleHooks), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Start the app + if err := app.Start(ctx); err != nil { + t.Fatalf("Start failed: %v", err) + } + + // Verify that OnStart was called + if !mockLogger.onStartCalled { + t.Error("OnStart hook was not called") + } + + // Stop the app + if err := app.Stop(ctx); err != nil { + t.Errorf("Stop failed: %v", err) + } + + // Verify that OnStop was called + if !mockLogger.onStopCalled { + t.Error("OnStop hook was not called") + } +} + +// mockConfigProvider is a mock implementation of ConfigProvider for testing +type mockConfigProvider struct { + values map[string]any +} + +func (m *mockConfigProvider) Get(key string) any { + return m.values[key] +} + +func (m *mockConfigProvider) Unmarshal(_ any) error { + return nil +} + +func (m *mockConfigProvider) GetString(key string) string { + if val, ok := m.values[key]; ok { + if str, ok := val.(string); ok { + return str + } + } + return "" +} + +func (m *mockConfigProvider) GetInt(key string) int { + if val, ok := m.values[key]; ok { + if i, ok := val.(int); ok { + return i + } + } + return 0 +} + +func (m *mockConfigProvider) GetBool(key string) bool { + if val, ok := m.values[key]; ok { + if b, ok := val.(bool); ok { + return b + } + } + return false +} + +func (m *mockConfigProvider) GetStringSlice(key string) []string { + if val, ok := m.values[key]; ok { + if slice, ok := val.([]string); ok { + return slice + } + } + return nil +} + +func (m *mockConfigProvider) GetDuration(key string) time.Duration { + if val, ok := m.values[key]; ok { + if d, ok := val.(time.Duration); ok { + return d + } + } + return 0 +} + +func (m *mockConfigProvider) IsSet(key string) bool { + _, ok := m.values[key] + return ok +} + +// mockLogger is a mock implementation of Logger for testing +type mockLogger struct { + onStartCalled bool + onStopCalled bool +} + +func (m *mockLogger) Debug(_ string, _ ...logger.Field) {} +func (m *mockLogger) Info(msg string, _ ...logger.Field) { + if msg == "Application starting" { + m.onStartCalled = true + } + if msg == "Application shutting down" { + m.onStopCalled = true + } +} +func (m *mockLogger) Warn(_ string, _ ...logger.Field) {} +func (m *mockLogger) Error(_ string, _ ...logger.Field) {} +func (m *mockLogger) With(_ ...logger.Field) logger.Logger { + return m +} +func (m *mockLogger) WithContext(_ context.Context) logger.Logger { + return m +} diff --git a/internal/logger/middleware.go b/internal/logger/middleware.go new file mode 100644 index 0000000..a54d05f --- /dev/null +++ b/internal/logger/middleware.go @@ -0,0 +1,102 @@ +// Package logger provides HTTP middleware and context utilities for logging. +package logger + +import ( + "context" + + "git.dcentral.systems/toolz/goplt/pkg/logger" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +const ( + // RequestIDHeader is the HTTP header name for request ID. + RequestIDHeader = "X-Request-ID" +) + +// ContextKey is a custom type for context keys to avoid collisions. +// It is exported so modules can use it for setting context values. +type ContextKey string + +const ( + requestIDKey ContextKey = "request_id" + userIDKey ContextKey = "user_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/middleware_test.go b/internal/logger/middleware_test.go new file mode 100644 index 0000000..e84e4a9 --- /dev/null +++ b/internal/logger/middleware_test.go @@ -0,0 +1,359 @@ +package logger + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "git.dcentral.systems/toolz/goplt/pkg/logger" + "github.com/gin-gonic/gin" +) + +func TestRequestIDMiddleware_GenerateNewID(t *testing.T) { + // Cannot run in parallel: gin.SetMode() modifies global state + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(RequestIDMiddleware()) + router.GET("/test", func(c *gin.Context) { + requestID := RequestIDFromContext(c.Request.Context()) + if requestID == "" { + t.Error("Request ID should be generated") + } + c.JSON(http.StatusOK, gin.H{"request_id": requestID}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Check that request ID is in response header + requestID := w.Header().Get(RequestIDHeader) + if requestID == "" { + t.Error("Request ID should be in response header") + } +} + +func TestRequestIDMiddleware_UseExistingID(t *testing.T) { + // Cannot run in parallel: gin.SetMode() modifies global state + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(RequestIDMiddleware()) + router.GET("/test", func(c *gin.Context) { + requestID := RequestIDFromContext(c.Request.Context()) + if requestID != "existing-id" { + t.Errorf("Expected request ID 'existing-id', got %q", requestID) + } + c.JSON(http.StatusOK, gin.H{"request_id": requestID}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set(RequestIDHeader, "existing-id") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Check that the same request ID is in response header + requestID := w.Header().Get(RequestIDHeader) + if requestID != "existing-id" { + t.Errorf("Expected request ID 'existing-id' in header, got %q", requestID) + } +} + +func TestRequestIDFromContext(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ctx context.Context + want string + wantEmpty bool + }{ + { + name: "with request ID", + ctx: context.WithValue(context.Background(), requestIDKey, "test-id"), + want: "test-id", + wantEmpty: false, + }, + { + name: "without request ID", + ctx: context.Background(), + want: "", + wantEmpty: true, + }, + { + name: "with wrong type", + ctx: context.WithValue(context.Background(), requestIDKey, 123), + want: "", + wantEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := RequestIDFromContext(tt.ctx) + if tt.wantEmpty && got != "" { + t.Errorf("RequestIDFromContext() = %q, want empty string", got) + } + if !tt.wantEmpty && got != tt.want { + t.Errorf("RequestIDFromContext() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSetRequestID(t *testing.T) { + t.Parallel() + + ctx := context.Background() + newCtx := SetRequestID(ctx, "test-id") + + requestID := RequestIDFromContext(newCtx) + if requestID != "test-id" { + t.Errorf("SetRequestID failed, got %q, want %q", requestID, "test-id") + } + + // Original context should not have the ID + originalID := RequestIDFromContext(ctx) + if originalID != "" { + t.Error("Original context should not have request ID") + } +} + +func TestSetUserID(t *testing.T) { + t.Parallel() + + ctx := context.Background() + newCtx := SetUserID(ctx, "user-123") + + userID := UserIDFromContext(newCtx) + if userID != "user-123" { + t.Errorf("SetUserID failed, got %q, want %q", userID, "user-123") + } + + // Original context should not have the ID + originalID := UserIDFromContext(ctx) + if originalID != "" { + t.Error("Original context should not have user ID") + } +} + +func TestUserIDFromContext(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ctx context.Context + want string + wantEmpty bool + }{ + { + name: "with user ID", + ctx: context.WithValue(context.Background(), userIDKey, "user-123"), + want: "user-123", + wantEmpty: false, + }, + { + name: "without user ID", + ctx: context.Background(), + want: "", + wantEmpty: true, + }, + { + name: "with wrong type", + ctx: context.WithValue(context.Background(), userIDKey, 123), + want: "", + wantEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := UserIDFromContext(tt.ctx) + if tt.wantEmpty && got != "" { + t.Errorf("UserIDFromContext() = %q, want empty string", got) + } + if !tt.wantEmpty && got != tt.want { + t.Errorf("UserIDFromContext() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestLoggingMiddleware(t *testing.T) { + // Cannot run in parallel: gin.SetMode() modifies global state + + // Create a mock logger that records log calls + mockLog := &mockLoggerForMiddleware{} + mockLog.logs = make([]logEntry, 0) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(RequestIDMiddleware()) + router.Use(LoggingMiddleware(mockLog)) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "test"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Verify that logs were recorded + if len(mockLog.logs) < 2 { + t.Fatalf("Expected at least 2 log entries (request + response), got %d", len(mockLog.logs)) + } + + // Check request log + requestLog := mockLog.logs[0] + if requestLog.message != "HTTP request" { + t.Errorf("Expected 'HTTP request' log, got %q", requestLog.message) + } + + // Check response log + responseLog := mockLog.logs[1] + if responseLog.message != "HTTP response" { + t.Errorf("Expected 'HTTP response' log, got %q", responseLog.message) + } +} + +func TestLoggingMiddleware_WithRequestID(t *testing.T) { + // Cannot run in parallel: gin.SetMode() modifies global state + + // Create a mock logger + mockLog := &mockLoggerForMiddleware{} + mockLog.logs = make([]logEntry, 0) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(RequestIDMiddleware()) + router.Use(LoggingMiddleware(mockLog)) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "test"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set(RequestIDHeader, "custom-request-id") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Verify that request ID is in the logs + if len(mockLog.logs) < 2 { + t.Fatalf("Expected at least 2 log entries, got %d", len(mockLog.logs)) + } + + // The logger should have received context with request ID + // (We can't easily verify this without exposing internal state, but we can check the logs were made) +} + +func TestRequestIDMiddleware_MultipleRequests(t *testing.T) { + // Cannot run in parallel: gin.SetMode() modifies global state + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(RequestIDMiddleware()) + router.GET("/test", func(c *gin.Context) { + requestID := RequestIDFromContext(c.Request.Context()) + c.JSON(http.StatusOK, gin.H{"request_id": requestID}) + }) + + // Make multiple requests + requestIDs := make(map[string]bool) + for i := 0; i < 10; i++ { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + requestID := w.Header().Get(RequestIDHeader) + if requestID == "" { + t.Errorf("Request %d: Request ID should be generated", i) + continue + } + + if requestIDs[requestID] { + t.Errorf("Request ID %q was duplicated", requestID) + } + requestIDs[requestID] = true + } +} + +func TestSetRequestID_Overwrite(t *testing.T) { + t.Parallel() + + ctx := context.WithValue(context.Background(), requestIDKey, "old-id") + newCtx := SetRequestID(ctx, "new-id") + + requestID := RequestIDFromContext(newCtx) + if requestID != "new-id" { + t.Errorf("SetRequestID failed to overwrite, got %q, want %q", requestID, "new-id") + } +} + +func TestSetUserID_Overwrite(t *testing.T) { + t.Parallel() + + ctx := context.WithValue(context.Background(), userIDKey, "old-user") + newCtx := SetUserID(ctx, "new-user") + + userID := UserIDFromContext(newCtx) + if userID != "new-user" { + t.Errorf("SetUserID failed to overwrite, got %q, want %q", userID, "new-user") + } +} + +// mockLoggerForMiddleware is a mock logger that records log calls for testing +type mockLoggerForMiddleware struct { + logs []logEntry +} + +type logEntry struct { + message string + fields []logger.Field +} + +func (m *mockLoggerForMiddleware) Debug(msg string, fields ...logger.Field) { + m.logs = append(m.logs, logEntry{message: msg, fields: fields}) +} + +func (m *mockLoggerForMiddleware) Info(msg string, fields ...logger.Field) { + m.logs = append(m.logs, logEntry{message: msg, fields: fields}) +} + +func (m *mockLoggerForMiddleware) Warn(msg string, fields ...logger.Field) { + m.logs = append(m.logs, logEntry{message: msg, fields: fields}) +} + +func (m *mockLoggerForMiddleware) Error(msg string, fields ...logger.Field) { + m.logs = append(m.logs, logEntry{message: msg, fields: fields}) +} + +func (m *mockLoggerForMiddleware) With(_ ...logger.Field) logger.Logger { + return m +} + +func (m *mockLoggerForMiddleware) WithContext(_ context.Context) logger.Logger { + return m +} diff --git a/internal/logger/zap_logger.go b/internal/logger/zap_logger.go new file mode 100644 index 0000000..e4eccee --- /dev/null +++ b/internal/logger/zap_logger.go @@ -0,0 +1,129 @@ +package logger + +import ( + "context" + + "git.dcentral.systems/toolz/goplt/pkg/logger" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// Note: contextKey constants are defined in middleware.go to avoid duplication + +// 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() ContextKey { + 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() ContextKey { + return userIDKey +} diff --git a/internal/logger/zap_logger_test.go b/internal/logger/zap_logger_test.go new file mode 100644 index 0000000..3f76bbc --- /dev/null +++ b/internal/logger/zap_logger_test.go @@ -0,0 +1,368 @@ +package logger + +import ( + "context" + "testing" + + "git.dcentral.systems/toolz/goplt/pkg/logger" + "go.uber.org/zap" +) + +func TestNewZapLogger_JSONFormat(t *testing.T) { + t.Parallel() + + log, err := NewZapLogger("info", "json") + if err != nil { + t.Fatalf("NewZapLogger failed: %v", err) + } + + if log == nil { + t.Fatal("NewZapLogger returned nil") + } + + // Verify it implements the interface (compile-time check) + _ = log + + // Test that it can log + log.Info("test message") +} + +func TestNewZapLogger_ConsoleFormat(t *testing.T) { + t.Parallel() + + log, err := NewZapLogger("info", "console") + if err != nil { + t.Fatalf("NewZapLogger failed: %v", err) + } + + if log == nil { + t.Fatal("NewZapLogger returned nil") + } + + // Test that it can log + log.Info("test message") +} + +func TestNewZapLogger_InvalidLevel(t *testing.T) { + t.Parallel() + + log, err := NewZapLogger("invalid", "json") + if err != nil { + t.Fatalf("NewZapLogger failed: %v", err) + } + + if log == nil { + t.Fatal("NewZapLogger returned nil") + } + + // Should default to info level + log.Info("test message") +} + +func TestNewZapLogger_AllLevels(t *testing.T) { + t.Parallel() + + levels := []string{"debug", "info", "warn", "error"} + + for _, level := range levels { + t.Run(level, func(t *testing.T) { + t.Parallel() + + log, err := NewZapLogger(level, "json") + if err != nil { + t.Fatalf("NewZapLogger(%q) failed: %v", level, err) + } + + if log == nil { + t.Fatalf("NewZapLogger(%q) returned nil", level) + } + + // Test logging at each level + log.Debug("debug message") + log.Info("info message") + log.Warn("warn message") + log.Error("error message") + }) + } +} + +func TestZapLogger_With(t *testing.T) { + t.Parallel() + + log, err := NewZapLogger("info", "json") + if err != nil { + t.Fatalf("NewZapLogger failed: %v", err) + } + + childLog := log.With( + logger.String("key", "value"), + logger.Int("number", 42), + ) + + if childLog == nil { + t.Fatal("With returned nil") + } + + // Verify it's a different logger instance + if childLog == log { + t.Error("With should return a new logger instance") + } + + // Test that child logger can log + childLog.Info("test message with fields") +} + +func TestZapLogger_WithContext_RequestID(t *testing.T) { + t.Parallel() + + log, err := NewZapLogger("info", "json") + if err != nil { + t.Fatalf("NewZapLogger failed: %v", err) + } + + ctx := context.WithValue(context.Background(), requestIDKey, "test-request-id") + contextLog := log.WithContext(ctx) + + if contextLog == nil { + t.Fatal("WithContext returned nil") + } + + // Test that context logger can log + contextLog.Info("test message with request ID") +} + +func TestZapLogger_WithContext_UserID(t *testing.T) { + t.Parallel() + + log, err := NewZapLogger("info", "json") + if err != nil { + t.Fatalf("NewZapLogger failed: %v", err) + } + + ctx := context.WithValue(context.Background(), userIDKey, "test-user-id") + contextLog := log.WithContext(ctx) + + if contextLog == nil { + t.Fatal("WithContext returned nil") + } + + // Test that context logger can log + contextLog.Info("test message with user ID") +} + +func TestZapLogger_WithContext_Both(t *testing.T) { + t.Parallel() + + log, err := NewZapLogger("info", "json") + if err != nil { + t.Fatalf("NewZapLogger failed: %v", err) + } + + ctx := context.WithValue(context.Background(), requestIDKey, "test-request-id") + ctx = context.WithValue(ctx, userIDKey, "test-user-id") + contextLog := log.WithContext(ctx) + + if contextLog == nil { + t.Fatal("WithContext returned nil") + } + + // Test that context logger can log + contextLog.Info("test message with both IDs") +} + +func TestZapLogger_WithContext_EmptyContext(t *testing.T) { + t.Parallel() + + log, err := NewZapLogger("info", "json") + if err != nil { + t.Fatalf("NewZapLogger failed: %v", err) + } + + ctx := context.Background() + contextLog := log.WithContext(ctx) + + if contextLog == nil { + t.Fatal("WithContext returned nil") + } + + // With empty context, should return the same logger + if contextLog != log { + t.Error("WithContext with empty context should return the same logger") + } +} + +func TestZapLogger_LoggingMethods(t *testing.T) { + t.Parallel() + + log, err := NewZapLogger("debug", "json") + if err != nil { + t.Fatalf("NewZapLogger failed: %v", err) + } + + fields := []logger.Field{ + logger.String("key", "value"), + logger.Int("number", 42), + logger.Bool("flag", true), + } + + // Test all logging methods + log.Debug("debug message", fields...) + log.Info("info message", fields...) + log.Warn("warn message", fields...) + log.Error("error message", fields...) +} + +func TestConvertFields(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fields []logger.Field + }{ + { + name: "empty fields", + fields: []logger.Field{}, + }, + { + name: "valid zap fields", + fields: []logger.Field{ + zap.String("key", "value"), + zap.Int("number", 42), + }, + }, + { + name: "mixed fields", + fields: []logger.Field{ + logger.String("key", "value"), + zap.Int("number", 42), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + log, err := NewZapLogger("info", "json") + if err != nil { + t.Fatalf("NewZapLogger failed: %v", err) + } + + // Test that convertFields works by logging + log.Info("test message", tt.fields...) + }) + } +} + +func TestRequestIDKey(t *testing.T) { + t.Parallel() + + key := RequestIDKey() + if key == "" { + t.Error("RequestIDKey returned empty contextKey") + } + + if key != requestIDKey { + t.Errorf("RequestIDKey() = %v, want %v", key, requestIDKey) + } +} + +func TestUserIDKey(t *testing.T) { + t.Parallel() + + key := UserIDKey() + if key == "" { + t.Error("UserIDKey returned empty contextKey") + } + + if key != userIDKey { + t.Errorf("UserIDKey() = %v, want %v", key, userIDKey) + } +} + +func TestZapLogger_ChainedWith(t *testing.T) { + t.Parallel() + + log, err := NewZapLogger("info", "json") + if err != nil { + t.Fatalf("NewZapLogger failed: %v", err) + } + + // Chain multiple With calls + childLog := log.With( + logger.String("parent", "value1"), + ).With( + logger.String("child", "value2"), + ) + + if childLog == nil { + t.Fatal("Chained With returned nil") + } + + childLog.Info("test message with chained fields") +} + +func TestZapLogger_WithContext_ChainedWith(t *testing.T) { + t.Parallel() + + log, err := NewZapLogger("info", "json") + if err != nil { + t.Fatalf("NewZapLogger failed: %v", err) + } + + ctx := context.WithValue(context.Background(), requestIDKey, "test-id") + contextLog := log.WithContext(ctx).With( + logger.String("additional", "field"), + ) + + if contextLog == nil { + t.Fatal("Chained WithContext and With returned nil") + } + + contextLog.Info("test message with context and additional fields") +} + +// Benchmark tests +func BenchmarkZapLogger_Info(b *testing.B) { + log, err := NewZapLogger("info", "json") + if err != nil { + b.Fatalf("NewZapLogger failed: %v", err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + log.Info("benchmark message") + } +} + +func BenchmarkZapLogger_InfoWithFields(b *testing.B) { + log, err := NewZapLogger("info", "json") + if err != nil { + b.Fatalf("NewZapLogger failed: %v", err) + } + + fields := []logger.Field{ + logger.String("key1", "value1"), + logger.Int("key2", 42), + logger.Bool("key3", true), + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + log.Info("benchmark message", fields...) + } +} + +func BenchmarkZapLogger_WithContext(b *testing.B) { + log, err := NewZapLogger("info", "json") + if err != nil { + b.Fatalf("NewZapLogger failed: %v", err) + } + + ctx := context.WithValue(context.Background(), requestIDKey, "test-id") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = log.WithContext(ctx) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..2f268d5 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,43 @@ +// Package config provides the configuration management interface. +// It defines the ConfigProvider interface that implementations must satisfy. +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.). +// +//nolint:revive // ConfigProvider is a standard interface name pattern; stuttering is acceptable +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..12687ac --- /dev/null +++ b/pkg/logger/fields.go @@ -0,0 +1,34 @@ +// Package logger provides helper functions for creating structured logging fields. +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..a039fcc --- /dev/null +++ b/pkg/logger/global.go @@ -0,0 +1,71 @@ +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{} + +// Debug implements Logger.Debug as a no-op. +func (n *noOpLogger) Debug(_ string, _ ...Field) {} + +// Info implements Logger.Info as a no-op. +func (n *noOpLogger) Info(_ string, _ ...Field) {} + +// Warn implements Logger.Warn as a no-op. +func (n *noOpLogger) Warn(_ string, _ ...Field) {} + +// Error implements Logger.Error as a no-op. +func (n *noOpLogger) Error(_ string, _ ...Field) {} + +// With implements Logger.With as a no-op. +func (n *noOpLogger) With(_ ...Field) Logger { return n } + +// WithContext implements Logger.WithContext as a no-op. +func (n *noOpLogger) WithContext(_ 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 +}