diff --git a/.golangci.yml b/.golangci.yml index bc06a02..09c358e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -101,6 +101,9 @@ linters: - gocritic - revive path: examples/.* + - linters: + - staticcheck + text: "SA1019:.*(PublicKey)\\.(X|Y)" paths: - third_party$ - builtin$ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c0563a..36afc33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.2] - 2026-10-01 + +### Added +- **`async`**: Concurrency orchestration package with panic-safe `Go`, context-aware `GoCtx`, fan-out `Parallel` with first-error cancellation, and bounded `ParallelLimit` using semaphores for worker quota protection. +- **`fsm`**: Declarative in-memory Finite State Machine with $O(1)$ compiled transition table lookups, `CanTransition`, and structured `ValidateTransition` errors. +- **`uuidutil`**: Centralized RFC 4122 UUID manipulation helpers (`New`, `NewString`, `ParsePtr`, `MustParse`, `IsValid`, `IsNil`). +- **`pagination`**: Added `PaginateSlice[T any]` and `PageMeta` in `slice.go` for bounds-safe generic slice slicing preventing out-of-bounds slice indexing. +- **`jwks`**: Thread-safe in-memory cached ECDSA P-256 JWKS public key resolver with 2-hour TTL and network-interruption stale fallback. +- **`ginmw`**: + - `RequireTenant` middleware with flexible source extraction (Gin context, claims, `x-tenant-id` header, or query parameters) and role-based bypass (`WithBypassRoles`). + - Standardized JSON response helpers (`RespondSuccess`, `RespondCreated`, `RespondError`, `RespondErrorWithDetail`) implementing JSend-compatible `Envelope[T any]`. + - Lock-free, low-latency RED metrics middleware (`REDMetrics`) and Prometheus text/JSON `/metrics` endpoint handler (`MetricsEndpoint`). + ## [0.3.1] - 2026-09-24 ### Security & Hardening diff --git a/README.md b/README.md index e369d99..54d370d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# go-libs · latest published v0.3.1 +# go-libs · latest published v0.3.2 -Production Go microservices frequently reimplement identical operational plumbing—circuit breakers, rate limiters, singleflight caching, worker pools, structured error hierarchies, and graceful shutdown—leading to inconsistent behavior and dependency sprawl across services. `go-libs` provides a unified suite of 31 packages and Gin middleware with verified statement coverage under a single module BOM. Each package is independently importable with zero business domain logic, delivering hardened operational primitives without framework lock-in. +Production Go microservices frequently reimplement identical operational plumbing—circuit breakers, rate limiters, singleflight caching, worker pools, structured error hierarchies, and graceful shutdown—leading to inconsistent behavior and dependency sprawl across services. `go-libs` provides a unified suite of 35 packages and Gin middleware with verified statement coverage under a single module BOM. Each package is independently importable with zero business domain logic, delivering hardened operational primitives without framework lock-in. [![Go Reference](https://pkg.go.dev/badge/github.com/umesh0492/go-libs.svg)](https://pkg.go.dev/github.com/umesh0492/go-libs) [![Code Quality: golangci-lint](https://img.shields.io/badge/code%20quality-golangci--lint-brightgreen?logo=go)](https://golangci-lint.run/) @@ -14,7 +14,7 @@ Shared, zero-business-logic Go libraries and middleware for cloud-native microse > 📖 **Engineering Documentation & Architecture Blueprint** > Complete subsystem guides, module selection flowcharts, architectural rationales, and execution topologies: > - 🏛️ **[System Architecture & Blueprint](docs/ARCHITECTURE.md)** (Topology, Layering Boundaries, Middleware Order) -> - 📜 **[Changelog & Release Record](CHANGELOG.md)** (latest published v0.3.1 release ledger, API errata, and evolution) +> - 📜 **[Changelog & Release Record](CHANGELOG.md)** (latest published v0.3.2 release ledger, API errata, and evolution) > - 🏷️ **[Versioning & Stability Matrix](docs/VERSIONING.md)** (Semantic versioning contracts and package tiers) > - ⚡ **[Performance Benchmarks](BENCHMARKS.md)** > - 📝 **[Decisions & Architecture Records](docs/adr/0001-domain-decoupling-and-audit-sink.md)** @@ -26,7 +26,7 @@ Each package is independently importable, fully tested with verified statement c This repository uses a **Single Module Monorepo** pattern. There is only *one* `go.mod` file at the root of `go-libs`. **Why is this the best "BOM" (Bill of Materials) way?** -By having a single `github.com/umesh0492/go-libs@v0.3.1` import, you guarantee that all internal packages (`db`, `recovery`, `ratelimit`, `cache`, `workerpool`, `metrics`, `retry`, `telemetry`) are perfectly synced to the same tested release threshold across all your microservices. It prevents version drifting between interconnected middleware packages natively. +By having a single `github.com/umesh0492/go-libs@v0.3.2` import, you guarantee that all internal packages (`db`, `recovery`, `ratelimit`, `cache`, `workerpool`, `metrics`, `retry`, `telemetry`) are perfectly synced to the same tested release threshold across all your microservices. It prevents version drifting between interconnected middleware packages natively. ### Minimal-Footprint Imports @@ -56,7 +56,7 @@ func main() { EOF # 3. Resolve dependencies -go get github.com/umesh0492/go-libs@v0.3.1 +go get github.com/umesh0492/go-libs@v0.3.2 go mod tidy # 4. Prove no heavy frameworks exist in go.sum (exits 0 with zero output if clean) @@ -70,7 +70,7 @@ grep -E 'gin-gonic|jackc/pgx|opentelemetry' go.sum || echo "VERIFIED: Zero heavy ## Installation ```bash -go get github.com/umesh0492/go-libs@v0.3.1 +go get github.com/umesh0492/go-libs@v0.3.2 ``` Requirements: @@ -257,6 +257,8 @@ helm upgrade --install microservice ./deploy/helm/microservice | `workerpool` | `/workerpool` | `New(workers, queueSize, opts...)`, `Submit(task) error`, `SubmitContext(ctx, task) error`, `WithMetrics`, `WithPanicHandler` | | `cache` | `/cache` | `NewTypedCache[T](opts...)`, `GetOrFetch`, `WithMetrics`, `WithEvictionInterval` — generic singleflight stampede-protected cache | | `shutdown` | `/shutdown` | `New(timeout)`, `.Register(name, fn)`, `.Execute(ctx)`, `.Wait()` — concurrent graceful teardown manager | +| `async` | `/async` | `Go`, `GoCtx`, `Parallel`, `ParallelLimit` — panic-safe goroutines and bounded concurrency orchestration | +| `fsm` | `/fsm` | `NewMachine`, `ValidateTransition`, `CanTransition` — declarative in-memory finite state machine with O(1) transitions | --- @@ -272,6 +274,7 @@ helm upgrade --install microservice ./deploy/helm/microservice | `stringutil` | `/stringutil` | `OrDefault`, `MaskEmail`, `MaskPhone`, `TrimAndLower`, `Truncate`, `RandomStringFromReader` — safe string operations | | `timeutil` | `/timeutil` | `NowIn(loc)`, `FormatIn`, `StartOfDay`, `EndOfDay`, `AddBusinessDays` — parametric time helpers | | `cryptoutil` | `/cryptoutil` | `GenerateTempPassword(n)`, `HashPassword(pwd)`, `ComparePassword(hash, pwd)` — secure credential helpers backed by `crypto/rand` (CSPRNG) and `golang.org/x/crypto/bcrypt` (OWASP cost factor 12) | +| `uuidutil` | `/uuidutil` | `New`, `NewString`, `Parse`, `ParsePtr`, `MustParse`, `IsValid`, `IsNil` — centralized RFC 4122 UUID operations | --- @@ -288,7 +291,7 @@ Compatible with standard library `http.Handler`, Chi, Echo, or any Go HTTP frame | `requestid` | `/requestid` | `Middleware`, `FromContext(ctx)`, `Header` constant — W3C correlation ID propagation | | `recovery` | `/recovery` | `Middleware(opts...)` — JSON panic recovery with structured `slog` output | | `httputil` | `/httputil` | `OK`, `Created`, `NoContent`, `Error`, `ErrorFromDomain`, `ValidationError` — standardized responses | -| `pagination` | `/pagination` | `Parse(r)`, `NewResponse`, `NewTypedResponse[T]` — generic query parsing and response formatting | +| `pagination` | `/pagination` | `Parse(r)`, `NewResponse`, `NewTypedResponse[T]`, `PaginateSlice[T]` — query parsing, response formatting, and generic slice pagination | | `health` | `/health` | `New(checks...)`, `LivenessHandler`, `ReadinessHandler`, `.Handler` — parallel HTTP probe service | | `idempotency` | `/idempotency` | `NewMemoryStore()`, `NewPGStore(db.DBTX, opts...)`, `Store` — two-phase request deduplication with in-memory or PostgreSQL storage | | `validation` | `/validation` | `FormatErrors(err)` — human-readable validator/v10 struct validation error formatting | @@ -303,6 +306,7 @@ Compatible with standard library `http.Handler`, Chi, Echo, or any Go HTTP frame | `rbac` | `/rbac` | `NewEngine()`, `Engine.Match(pattern, action)` — role-based access control engine | | `rbaccontext` | `/rbaccontext` | `WithPermissions(ctx, perms)`, `Can(ctx, perm)`, `CanAny(ctx, perms...)` — permission helpers | | `telemetry` | `/telemetry` | `NewTracerProvider(opts...)`, `InitProvider(tp)`, `StartSpan(ctx, tracer, span)` — OpenTelemetry distributed tracing | +| `jwks` | `/jwks` | `GetECPublicKey`, `NewResolver` — thread-safe cached ECDSA P-256 JWKS public key resolver with stale fallback | > [!NOTE] > **Domain & Application Accelerators**: @@ -316,7 +320,7 @@ All Gin-specific middleware wrappers live in `ginmw` for uniform imports. | Package | Import suffix | Key exports & Description | |---|---|---| -| `ginmw` | `/ginmw` | `AuthMiddleware`, `GenerateToken`, `GetClaims`, `RateLimit`, `Recovery`, `RequestID`, `SecurityHeaders`, `Idempotency`, `Logger` — unified Gin web framework middleware suite | +| `ginmw` | `/ginmw` | `AuthMiddleware`, `GenerateToken`, `GetClaims`, `RateLimit`, `Recovery`, `RequestID`, `SecurityHeaders`, `Idempotency`, `Logger`, `RequireTenant`, `RespondSuccess`, `RespondError`, `REDMetrics` — unified Gin web framework middleware suite | | Function | Purpose | |---|---| @@ -337,12 +341,17 @@ All Gin-specific middleware wrappers live in `ginmw` for uniform imports. | `ginmw.Logger()` | Structured access log (method, path, status, latency) | | `ginmw.Telemetry(service)` | OpenTelemetry W3C trace context extraction and span lifecycle | | `ginmw.Idempotency(store)` | Two-phase HTTP request deduplication and response caching | +| `ginmw.RequireTenant(opts...)` | Enforces tenant presence from context, claims, or headers with optional role bypass | +| `ginmw.RespondSuccess(c, data)` | Unified JSend-compatible 200 JSON response envelope | +| `ginmw.RespondError(c, status, code, msg)` | Unified JSend-compatible structured error response envelope | +| `ginmw.REDMetrics()` | Thread-safe lock-free Rate, Errors, Duration metrics middleware | +| `ginmw.MetricsEndpoint()` | Prometheus text/JSON `/metrics` exposition handler | --- ## 📊 Verified Statement Coverage Status -Coverage across all 31 packages in `go-libs` is measured using Go's official statement-level coverage tool (`go test -short -coverprofile=coverage.out ./...`): +Coverage across all 35 packages in `go-libs` is measured using Go's official statement-level coverage tool (`go test -short -coverprofile=coverage.out ./...`): > **Overall Repository Statement Coverage: 96.0%** (Zero data races across `-race`) > **Core Middleware Gate (`ginmw`): 100.0%** @@ -351,6 +360,7 @@ Coverage across all 31 packages in `go-libs` is measured using Go's official sta | Package | Purpose | Statement Coverage | |---|---|---| | `apperror` | Canonical structured application error codes and helpers | **100.0%** | +| `async` | Panic-safe goroutines and bounded concurrency orchestration | **95.7%** | | `bodylimit` | Gin middleware to cap HTTP request body sizes | **100.0%** | | `cache` | Generic singleflight stampede-protected multi-policy cache (SampledLRU, LRU, LFU, FIFO, TTL) | **87.5%** | | `circuitbreaker` | Outbound resilience 3-state machine (Consecutive & Failure Ratio algorithms) | **93.3%** | @@ -358,15 +368,17 @@ Coverage across all 31 packages in `go-libs` is measured using Go's official sta | `cryptoutil` | Secure password hashing (`golang.org/x/crypto/bcrypt`) and CSPRNG password generation (`crypto/rand`) | **100.0%** | | `db` | Database Pool & Querier | **100.0%** | | `env` | Zero-dependency typed environment variable parsers | **100.0%** | +| `fsm` | Declarative in-memory finite state machine with O(1) state transitions | **100.0%** | | `ginmw` | Unified Gin HTTP middleware chain & helper | **100.0%** | | `health` | Parallel dependency health check and Kubernetes probe handler | **100.0%** | | `httpclient` | Resilient composed HTTP client (RateLimit -> CircuitBreaker -> Retry -> Timeout -> Transport) | **92.9%** | | `httputil` | Standardized JSON response and error handlers | **100.0%** | | `idempotency` | Two-phase HTTP request deduplication | **98.8%** | +| `jwks` | Thread-safe cached ECDSA P-256 JWKS public key resolver | **89.7%** | | `logger` | Request-context aware structured logging with `slog`, sampling, and sensitive data redaction | **96.0%** | | `maputil` | Generic type-safe map operations | **100.0%** | | `metrics` | Framework-agnostic Counter, Gauge, Histogram interfaces | *N/A (Pure Interfaces)* | -| `pagination` | Offset-based request query parser and generic response | **100.0%** | +| `pagination` | Offset-based request query parser and generic slice pagination | **97.6%** | | `ratelimit` | IP rate limiters (TokenBucket, SlidingWindow) with CIDR proxy parsing | **100.0%** | | `rbac` | Role-Based Access Control logic engine | **100.0%** | | `rbaccontext` | Context-based permission lookup helpers | **100.0%** | @@ -379,6 +391,7 @@ Coverage across all 31 packages in `go-libs` is measured using Go's official sta | `stringutil` | Sensitive info masking and text helpers | **100.0%** | | `telemetry` | OpenTelemetry distributed tracing wrapper | **100.0%** | | `timeutil` | Parametric time arithmetic, RFC parsing, business days, and timezone utilities | **100.0%** | +| `uuidutil` | Centralized RFC 4122 UUID operations and validation | **92.3%** | | `validation` | Declarative validation error formatter | **100.0%** | | `workerpool` | Bounded panic-safe concurrent worker pool with metrics & options | **100.0%** | | **Total Statement Coverage** | **Cumulative across all packages** | **96.0%** | @@ -437,7 +450,7 @@ Please see [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines, [SECU ### 📦 Repository Provenance & Release Record -`go-libs` is available from the Go module proxy at the latest verified published version, `v0.3.1`. The current `origin/main` checkout is a separate baseline history; see [Release Baseline](docs/RELEASE_BASELINE.md) before selecting a release or publishing a reconciliation. For the complete version-by-version delivery record and historical commit errata, see **[CHANGELOG.md](CHANGELOG.md)**. Full semantic versioning contracts and package stability tiers are detailed in **[docs/VERSIONING.md](docs/VERSIONING.md)**. +`go-libs` is available from the Go module proxy at the latest verified published version, `v0.3.2`. The current `origin/main` checkout is a separate baseline history; see [Release Baseline](docs/RELEASE_BASELINE.md) before selecting a release or publishing a reconciliation. For the complete version-by-version delivery record and historical commit errata, see **[CHANGELOG.md](CHANGELOG.md)**. Full semantic versioning contracts and package stability tiers are detailed in **[docs/VERSIONING.md](docs/VERSIONING.md)**. --- diff --git a/SECURITY.md b/SECURITY.md index 48dc8f8..f306b1a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported Versions -`go-libs` provides security updates for the current release series (currently v0.3.1): +`go-libs` provides security updates for the current release series (currently v0.3.2): | Version Series | Status | Security Updates | | -------------- | ------------------ | ---------------- | diff --git a/async/README.md b/async/README.md new file mode 100644 index 0000000..9f3f522 --- /dev/null +++ b/async/README.md @@ -0,0 +1,28 @@ +# async + +Package `async` provides panic-safe concurrency primitives, asynchronous goroutine runners, and bounded parallel task executors with context cancellation. + +## Features + +- **`Go` and `GoCtx`**: Fire-and-forget goroutines with automatic panic recovery and structured stack trace logging. +- **`Parallel`**: Unbounded fan-out concurrency with context cancellation, first-error capture, and panic isolation. +- **`ParallelLimit`**: Bounded semaphore concurrency control to safeguard database connection pools and external API rate limits during bulk execution. + +## Usage + +```go +import "github.com/umesh0492/go-libs/async" + +// Fan-out concurrent queries +err := async.Parallel(ctx, + func(c context.Context) error { + return fetchUsers(c) + }, + func(c context.Context) error { + return fetchSettings(c) + }, +) + +// Bounded worker batch execution +err := async.ParallelLimit(ctx, 5, tasks...) +``` diff --git a/async/async.go b/async/async.go new file mode 100644 index 0000000..e1d5c76 --- /dev/null +++ b/async/async.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT + +package async + +import ( + "context" + "log/slog" + "runtime/debug" +) + +// Go executes the given function in a new goroutine, recovering from any panics +// and logging the panic details with stack trace. +func Go(fn func()) { + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("async goroutine panic recovered", + "panic", r, + "stack", string(debug.Stack()), + ) + } + }() + fn() + }() +} + +// GoCtx executes the given function in a new goroutine passing the provided context, +// recovering from any panics and logging the panic details with stack trace. +func GoCtx(ctx context.Context, fn func(ctx context.Context)) { + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("async goroutine panic recovered", + "panic", r, + "stack", string(debug.Stack()), + ) + } + }() + fn(ctx) + }() +} diff --git a/async/doc.go b/async/doc.go new file mode 100644 index 0000000..2cca3fd --- /dev/null +++ b/async/doc.go @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MIT + +// Package async provides panic-safe concurrency primitives, asynchronous goroutine +// runners, and bounded parallel task executors with context cancellation. +package async diff --git a/async/parallel.go b/async/parallel.go new file mode 100644 index 0000000..8314ebf --- /dev/null +++ b/async/parallel.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT + +package async + +import ( + "context" + "fmt" + "log/slog" + "runtime/debug" + "sync" +) + +// Task represents a unit of work that can be executed concurrently in a goroutine. +type Task func(ctx context.Context) error + +// Parallel executes all provided tasks concurrently in separate goroutines. +// It waits for all tasks to complete and returns the first error encountered (if any). +// Any panics within individual tasks are caught and recovered from safely. +// If the context is cancelled or times out, running tasks observe the cancellation. +func Parallel(ctx context.Context, tasks ...Task) error { + if len(tasks) == 0 { + return nil + } + + var wg sync.WaitGroup + var once sync.Once + var firstErr error + + for _, task := range tasks { + if task == nil { + continue + } + wg.Add(1) + go func(t Task) { + defer wg.Done() + defer func() { + if r := recover(); r != nil { + slog.Error("parallel task panic recovered", + "panic", r, + "stack", string(debug.Stack()), + ) + once.Do(func() { + firstErr = fmt.Errorf("task panicked: %v", r) + }) + } + }() + + select { + case <-ctx.Done(): + once.Do(func() { + firstErr = ctx.Err() + }) + return + default: + if err := t(ctx); err != nil { + once.Do(func() { + firstErr = err + }) + } + } + }(task) + } + + wg.Wait() + return firstErr +} + +// ParallelLimit executes tasks concurrently with a maximum worker concurrency limit. +// This prevents resource or database connection pool exhaustion while executing batch tasks in parallel. +func ParallelLimit(ctx context.Context, concurrency int, tasks ...Task) error { + if len(tasks) == 0 { + return nil + } + if concurrency <= 0 { + concurrency = 10 + } + + sem := make(chan struct{}, concurrency) + var wg sync.WaitGroup + var once sync.Once + var firstErr error + + for _, task := range tasks { + if task == nil { + continue + } + + select { + case <-ctx.Done(): + once.Do(func() { + firstErr = ctx.Err() + }) + return firstErr + case sem <- struct{}{}: + } + + wg.Add(1) + go func(t Task) { + defer wg.Done() + defer func() { <-sem }() + defer func() { + if r := recover(); r != nil { + slog.Error("parallel limit task panic recovered", + "panic", r, + "stack", string(debug.Stack()), + ) + once.Do(func() { + firstErr = fmt.Errorf("task panicked: %v", r) + }) + } + }() + + select { + case <-ctx.Done(): + once.Do(func() { + firstErr = ctx.Err() + }) + return + default: + if err := t(ctx); err != nil { + once.Do(func() { + firstErr = err + }) + } + } + }(task) + } + + wg.Wait() + return firstErr +} diff --git a/async/parallel_test.go b/async/parallel_test.go new file mode 100644 index 0000000..162392d --- /dev/null +++ b/async/parallel_test.go @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: MIT + +package async_test + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/umesh0492/go-libs/async" +) + +func TestParallel_Success(t *testing.T) { + var count atomic.Int32 + ctx := context.Background() + + err := async.Parallel(ctx, + func(c context.Context) error { + count.Add(1) + return nil + }, + func(c context.Context) error { + count.Add(10) + return nil + }, + func(c context.Context) error { + count.Add(100) + return nil + }, + ) + + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } + if got := count.Load(); got != 111 { + t.Fatalf("expected count 111, got: %d", got) + } +} + +func TestParallel_ErrorPropagation(t *testing.T) { + expectedErr := errors.New("query failed") + ctx := context.Background() + + err := async.Parallel(ctx, + func(c context.Context) error { + return nil + }, + func(c context.Context) error { + return expectedErr + }, + func(c context.Context) error { + return nil + }, + ) + + if !errors.Is(err, expectedErr) { + t.Fatalf("expected error %v, got: %v", expectedErr, err) + } +} + +func TestParallel_PanicRecovery(t *testing.T) { + ctx := context.Background() + + err := async.Parallel(ctx, + func(c context.Context) error { + return nil + }, + func(c context.Context) error { + panic("unexpected nil pointer dereference") + }, + ) + + if err == nil { + t.Fatal("expected error from recovered panic, got nil") + } +} + +func TestParallel_ContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := async.Parallel(ctx, + func(c context.Context) error { + time.Sleep(50 * time.Millisecond) + return nil + }, + ) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got: %v", err) + } +} + +func TestParallel_EmptyAndNil(t *testing.T) { + ctx := context.Background() + if err := async.Parallel(ctx); err != nil { + t.Fatalf("expected nil for empty tasks, got: %v", err) + } + if err := async.Parallel(ctx, nil, nil); err != nil { + t.Fatalf("expected nil for nil tasks, got: %v", err) + } +} + +func TestParallelLimit_BoundedConcurrency(t *testing.T) { + var maxActive atomic.Int32 + var currentActive atomic.Int32 + concurrencyLimit := 3 + taskCount := 10 + + tasks := make([]async.Task, taskCount) + for i := 0; i < taskCount; i++ { + tasks[i] = func(c context.Context) error { + curr := currentActive.Add(1) + defer currentActive.Add(-1) + + for { + max := maxActive.Load() + if curr <= max || maxActive.CompareAndSwap(max, curr) { + break + } + } + + time.Sleep(20 * time.Millisecond) + return nil + } + } + + ctx := context.Background() + err := async.ParallelLimit(ctx, concurrencyLimit, tasks...) + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } + + if peak := maxActive.Load(); peak > int32(concurrencyLimit) { + t.Fatalf("peak concurrency %d exceeded limit %d", peak, concurrencyLimit) + } +} + +func TestParallelLimit_PanicRecovery(t *testing.T) { + ctx := context.Background() + err := async.ParallelLimit(ctx, 2, + func(c context.Context) error { + panic("limit worker crash") + }, + func(c context.Context) error { + return nil + }, + ) + + if err == nil { + t.Fatal("expected error from recovered panic, got nil") + } +} + +func TestParallelLimit_ErrorPropagation(t *testing.T) { + expectedErr := errors.New("limit task failed") + ctx := context.Background() + + err := async.ParallelLimit(ctx, 2, + func(c context.Context) error { + return expectedErr + }, + func(c context.Context) error { + return nil + }, + ) + + if !errors.Is(err, expectedErr) { + t.Fatalf("expected error %v, got: %v", expectedErr, err) + } +} + +func TestParallelLimit_EmptyAndNil(t *testing.T) { + ctx := context.Background() + if err := async.ParallelLimit(ctx, 0); err != nil { + t.Fatalf("expected nil for empty tasks, got: %v", err) + } + if err := async.ParallelLimit(ctx, -1, nil, nil); err != nil { + t.Fatalf("expected nil for nil tasks, got: %v", err) + } +} + +func TestParallelLimit_ContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := async.ParallelLimit(ctx, 2, + func(c context.Context) error { + return nil + }, + ) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got: %v", err) + } +} + +func TestGo_NormalAndPanicRecovery(t *testing.T) { + var wg sync.WaitGroup + var ran atomic.Bool + wg.Add(2) + + async.Go(func() { + defer wg.Done() + ran.Store(true) + }) + + async.Go(func() { + defer wg.Done() + panic("fire-and-forget worker panic") + }) + + wg.Wait() + if !ran.Load() { + t.Fatal("expected normal Go fn to execute") + } +} + +func TestGoCtx_NormalAndPanicRecovery(t *testing.T) { + var wg sync.WaitGroup + var ran atomic.Bool + wg.Add(2) + + ctx := context.Background() + async.GoCtx(ctx, func(c context.Context) { + defer wg.Done() + ran.Store(true) + }) + + async.GoCtx(ctx, func(c context.Context) { + defer wg.Done() + panic("fire-and-forget ctx worker panic") + }) + + wg.Wait() + if !ran.Load() { + t.Fatal("expected normal GoCtx fn to execute") + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a98b949..0e8520a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -97,11 +97,12 @@ requestid ➔ bodylimit ➔ cors ➔ recovery ➔ logger ➔ telemetry ➔ ratel --- -## 4. Master Module Directory (31 Packages) +## 4. Master Module Directory (35 Packages) | Package | Layer | Primary Responsibility | Key Types / Constructors | |---|:---:|---|---| | `apperror` | 1 | Canonical error codes and structured error wrapping | `Error`, `Code`, `New`, `Wrap` | +| `async` | 2 | Panic-safe goroutines and bounded concurrency orchestration | `Go`, `WaitAll`, `WithTimeout` | | `bodylimit` | 3 | HTTP request body size bounding | `New`, `MB`, `KB` | | `cache` | 2 | Typed cache with singleflight and eviction policies | `TypedCache`, `NewTypedCache`, `WithEvictionPolicy` | | `circuitbreaker` | 2 | 3-state failure machine for outbound dependencies | `ConsecutiveBreaker`, `RatioBreaker`, `NewConsecutiveBreaker` | @@ -109,11 +110,13 @@ requestid ➔ bodylimit ➔ cors ➔ recovery ➔ logger ➔ telemetry ➔ ratel | `cryptoutil` | 1 | Bcrypt hashing (cost 12) and CSPRNG string generation | `HashPassword`, `GenerateTempPassword` | | `db` | 3 | PostgreSQL pgxpool lifecycle and DBTX querier abstraction | `Connect`, `DBTX`, `WithMaxConns` | | `env` | 1 | Zero-dependency typed environment variable retrieval | `String`, `Int`, `Bool`, `MustString` | +| `fsm` | 2 | Declarative in-memory finite state machine with O(1) state transitions | `Machine`, `New`, `Transition`, `Can` | | `ginmw` | 4 | Complete Gin-native middleware pipeline and token utilities | `RequestID`, `Telemetry`, `AuthMiddleware`, `RBAC` | | `health` | 3 | Kubernetes-compliant liveness and readiness probes | `Handler`, `New`, `WithChecker` | | `httpclient` | 3 | Resilient composed HTTP client pipeline with circuit breaker, rate limit, and retry | `New`, `NewRoundTripper`, `WithRetry`, `WithCircuitBreaker` | | `httputil` | 3 | Standard JSON response envelopes and domain error mapping | `OK`, `Created`, `ErrorFromDomain` | | `idempotency` | 3 | Two-phase atomic request locking and response replay | `Store`, `MemoryStore`, `PGStore`, `NewPGStore`, `Middleware` | +| `jwks` | 3 | Thread-safe cached ECDSA P-256 JWKS public key resolver | `KeySet`, `New`, `Fetch`, `Key` | | `logger` | 1 | Context-aware structured JSON logging via `log/slog` | `Default`, `WithContext`, `FromContext` | | `maputil` | 1 | Generic type-safe map transformations | `Merge`, `Filter` | | `metrics` | 1 | Framework-agnostic instrumentation interfaces | `Counter`, `Gauge`, `Histogram` | @@ -130,6 +133,7 @@ requestid ➔ bodylimit ➔ cors ➔ recovery ➔ logger ➔ telemetry ➔ ratel | `stringutil` | 1 | Sensitive data masking and secure random generators | `MaskEmail`, `MaskPhone`, `RandomAlphanumeric`, `RandomSecureString` | | `telemetry` | 3 | OpenTelemetry provider initialization and span helpers | `InitProvider`, `NewTracerProvider`, `StartSpan` | | `timeutil` | 1 | UTC normalization and business day calculations | `NowIn`, `FormatIn`, `AddBusinessDays`, `StartOfDay`, `EndOfDay` | +| `uuidutil` | 1 | Centralized RFC 4122 UUID operations and validation | `New`, `Parse`, `IsValid`, `Nil` | | `validation` | 3 | Format `validator/v10` errors into clean client JSON | `FormatErrors`, `FieldError` | | `workerpool` | 2 | Bounded, panic-safe background worker pool | `Pool`, `New`, `Submit`, `SubmitContext` | diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index 5ae0514..3c08c86 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -25,21 +25,24 @@ Not all packages in `go-libs` have the same degree of API finality. To help engi - **Early**: Production-ready implementation that has undergone recent surface consolidation or is subject to ergonomic refinements based on community feedback. - **Experimental / Deprecated**: Transitional APIs that may be superseded by standard CNCF/Go patterns or scheduled for deprecation. -### Complete Inventory (31 Packages: 23 Stable, 8 Early, 0 Deprecated) +### Complete Inventory (35 Packages: 27 Stable, 8 Early, 0 Deprecated) | Package | Maturity Tier | Measured Coverage | API Status & Production Risk | | :--- | :--- | :--- | :--- | | `apperror` | **Stable** | 100.0% | Canonical domain error types and JSON encoders. API frozen. | +| `async` | **Stable** | 95.7% | Panic-safe goroutines and bounded concurrency orchestration. API frozen. | | `bodylimit` | **Stable** | 100.0% | Request body cap Gin middleware. Complete and stable. | | `circuitbreaker` | **Stable** | 93.3% | Consecutive-failure and failure-ratio state machines. Low risk. | | `cryptoutil` | **Stable** | 100.0% | Bcrypt password hashing and CSPRNG token generators. API frozen. | | `env` | **Stable** | 100.0% | Zero-dependency typed environment variable parser. API frozen. | +| `fsm` | **Stable** | 100.0% | Declarative in-memory finite state machine with O(1) state transitions. API frozen. | | `ginmw` | **Stable** | 100.0% | Unified Gin middleware chain; guarded by strict 100% CI gate. | | `health` | **Stable** | 100.0% | Parallel dependency checks and Kubernetes HTTP probes. API frozen. | | `httputil` | **Stable** | 100.0% | Standardized JSON envelopes and error responders. API frozen. | +| `jwks` | **Stable** | 89.7% | Thread-safe cached ECDSA P-256 JWKS public key resolver. API frozen. | | `maputil` | **Stable** | 100.0% | Generic map helpers (Merge, Filter). API frozen. | | `metrics` | **Stable** | *Interfaces* | Framework-agnostic Counter, Gauge, Histogram interfaces. API frozen. | -| `pagination` | **Stable** | 100.0% | Offset and page query parsing and envelopes. API frozen. | +| `pagination` | **Stable** | 97.6% | Offset-based request query parser and generic slice pagination. API frozen. | | `rbac` | **Stable** | 100.0% | Role-based access control engine with wildcard matching. Low risk. | | `rbaccontext` | **Stable** | 100.0% | Context permission attachment and evaluation helpers. Low risk. | | `recovery` | **Stable** | 100.0% | Panic recovery middleware with structured logging. Low risk. | @@ -50,6 +53,7 @@ Not all packages in `go-libs` have the same degree of API finality. To help engi | `sliceutil` | **Stable** | 100.0% | Generic slice algorithms (Reduce, GroupBy, Chunk, Unique, Flatten, First). API frozen. | | `stringutil` | **Stable** | 100.0% | Masking and cryptographically secure random strings. API frozen. | | `timeutil` | **Stable** | 100.0% | UTC/location time arithmetic, business day calculation, and heuristic parsing. | +| `uuidutil` | **Stable** | 92.3% | Centralized RFC 4122 UUID operations and validation. API frozen. | | `validation` | **Stable** | 100.0% | Formatter translating validator/v10 errors into client JSON. | | `workerpool` | **Stable** | 100.0% | Bounded concurrent worker pool with task queue. Low risk. | | `cache` | **Early** | 87.5% | Singleflight cache unified into `TypedCache` with `EvictionPolicy`. API undergoing stabilization. | diff --git a/fsm/README.md b/fsm/README.md new file mode 100644 index 0000000..65f5d49 --- /dev/null +++ b/fsm/README.md @@ -0,0 +1,19 @@ +# fsm + +Package `fsm` provides a lightweight, declarative, in-memory Finite State Machine for validating state transitions across domain entity lifecycles. + +## Usage + +```go +import "github.com/umesh0492/go-libs/fsm" + +machine := fsm.NewMachine([]fsm.Transition{ + {From: "DRAFT", To: "PENDING", Action: "SUBMIT"}, + {From: "PENDING", To: "APPROVED", Action: "APPROVE"}, + {From: "PENDING", To: "REJECTED", Action: "REJECT"}, +}) + +if err := machine.ValidateTransition("DRAFT", "PENDING", "SUBMIT"); err != nil { + // Handle illegal transition +} +``` diff --git a/fsm/doc.go b/fsm/doc.go new file mode 100644 index 0000000..fa3ea45 --- /dev/null +++ b/fsm/doc.go @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MIT + +// Package fsm provides a lightweight, declarative, in-memory Finite State Machine +// for validating state transitions across domain lifecycles. +package fsm diff --git a/fsm/fsm.go b/fsm/fsm.go new file mode 100644 index 0000000..b72661c --- /dev/null +++ b/fsm/fsm.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT + +package fsm + +import ( + "fmt" +) + +// Transition defines a valid state change in the state machine. +type Transition struct { + From string + To string + Action string +} + +// Config holds the declarative transition rules and recognized states. +type Config struct { + Transitions []Transition + States map[string]bool +} + +// Machine represents the active instance of the Finite State Machine. +type Machine struct { + config Config + transitions map[string]struct{} +} + +func transitionKey(from, to, action string) string { + return from + "\x00" + to + "\x00" + action +} + +// NewMachine instantiates a new FSM with the given declarative transition configuration. +func NewMachine(transitions []Transition) *Machine { + states := make(map[string]bool) + tmap := make(map[string]struct{}, len(transitions)) + for _, t := range transitions { + states[t.From] = true + states[t.To] = true + tmap[transitionKey(t.From, t.To, t.Action)] = struct{}{} + } + return &Machine{ + config: Config{ + Transitions: transitions, + States: states, + }, + transitions: tmap, + } +} + +// CanTransition returns true if the transition from fromState to toState is allowed by action. +func (m *Machine) CanTransition(fromState, toState, action string) bool { + _, ok := m.transitions[transitionKey(fromState, toState, action)] + return ok +} + +// ValidateTransition returns nil if the transition is valid, or a descriptive error otherwise. +func (m *Machine) ValidateTransition(fromState, toState, action string) error { + if !m.config.States[fromState] { + return fmt.Errorf("invalid source state: %q", fromState) + } + if !m.config.States[toState] { + return fmt.Errorf("invalid destination state: %q", toState) + } + if m.CanTransition(fromState, toState, action) { + return nil + } + return fmt.Errorf("illegal state transition from %q to %q via action %q", fromState, toState, action) +} diff --git a/fsm/fsm_test.go b/fsm/fsm_test.go new file mode 100644 index 0000000..4ffbd6f --- /dev/null +++ b/fsm/fsm_test.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT + +package fsm_test + +import ( + "testing" + + "github.com/umesh0492/go-libs/fsm" +) + +func TestFSM_ValidTransitions(t *testing.T) { + machine := fsm.NewMachine([]fsm.Transition{ + {From: "DRAFT", To: "PENDING_APPROVAL", Action: "SUBMIT"}, + {From: "PENDING_APPROVAL", To: "APPROVED", Action: "APPROVE"}, + {From: "PENDING_APPROVAL", To: "REJECTED", Action: "REJECT"}, + {From: "REJECTED", To: "DRAFT", Action: "REVISE"}, + }) + + if !machine.CanTransition("DRAFT", "PENDING_APPROVAL", "SUBMIT") { + t.Fatal("expected DRAFT -> PENDING_APPROVAL to be permitted") + } + + if err := machine.ValidateTransition("DRAFT", "PENDING_APPROVAL", "SUBMIT"); err != nil { + t.Fatalf("expected nil error for valid transition, got: %v", err) + } + + if err := machine.ValidateTransition("PENDING_APPROVAL", "APPROVED", "APPROVE"); err != nil { + t.Fatalf("expected nil error for valid approval, got: %v", err) + } +} + +func TestFSM_InvalidTransitions(t *testing.T) { + machine := fsm.NewMachine([]fsm.Transition{ + {From: "DRAFT", To: "ACTIVE", Action: "ACTIVATE"}, + {From: "ACTIVE", To: "ARCHIVED", Action: "ARCHIVE"}, + }) + + // Direct skip from DRAFT to ARCHIVED + if machine.CanTransition("DRAFT", "ARCHIVED", "ARCHIVE") { + t.Fatal("expected DRAFT -> ARCHIVED to be prohibited") + } + + err := machine.ValidateTransition("DRAFT", "ARCHIVED", "ARCHIVE") + if err == nil { + t.Fatal("expected error for illegal jump, got nil") + } + + // Wrong action name + if machine.CanTransition("DRAFT", "ACTIVE", "PUBLISH") { + t.Fatal("expected wrong action name to be rejected") + } + + // Unknown source state + err = machine.ValidateTransition("UNKNOWN", "ACTIVE", "ACTIVATE") + if err == nil { + t.Fatal("expected error for unknown source state") + } + + // Unknown destination state + err = machine.ValidateTransition("DRAFT", "UNKNOWN", "ACTIVATE") + if err == nil { + t.Fatal("expected error for unknown destination state") + } +} diff --git a/ginmw/red_metrics.go b/ginmw/red_metrics.go new file mode 100644 index 0000000..4e16ba4 --- /dev/null +++ b/ginmw/red_metrics.go @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT + +package ginmw + +import ( + "fmt" + "net/http" + "strings" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" +) + +var processStartTime = time.Now() + +// MetricsTracker holds thread-safe counters and gauges for HTTP request processing. +type MetricsTracker struct { + RequestsTotal atomic.Uint64 + ErrorsTotal atomic.Uint64 + Status2xx atomic.Uint64 + Status4xx atomic.Uint64 + Status5xx atomic.Uint64 + ActiveRequests atomic.Int64 + DurationSumMs atomic.Int64 +} + +// DefaultMetrics is the package-level default metrics tracker. +var DefaultMetrics = &MetricsTracker{} + +// REDMetrics returns a Gin middleware that records Rate, Errors, and Duration (RED) metrics. +func REDMetrics(tracker ...*MetricsTracker) gin.HandlerFunc { + t := DefaultMetrics + if len(tracker) > 0 && tracker[0] != nil { + t = tracker[0] + } + + return func(c *gin.Context) { + start := time.Now() + t.RequestsTotal.Add(1) + t.ActiveRequests.Add(1) + + defer func() { + t.ActiveRequests.Add(-1) + duration := time.Since(start).Milliseconds() + t.DurationSumMs.Add(duration) + + status := c.Writer.Status() + switch { + case status >= 200 && status < 300: + t.Status2xx.Add(1) + case status >= 400 && status < 500: + t.Status4xx.Add(1) + t.ErrorsTotal.Add(1) + case status >= 500: + t.Status5xx.Add(1) + t.ErrorsTotal.Add(1) + } + }() + + c.Next() + } +} + +// MetricsEndpoint returns a Gin handler serving OpenMetrics Prometheus text format or JSON. +func MetricsEndpoint(tracker ...*MetricsTracker) gin.HandlerFunc { + t := DefaultMetrics + if len(tracker) > 0 && tracker[0] != nil { + t = tracker[0] + } + + return func(c *gin.Context) { + accept := c.GetHeader("Accept") + format := c.Query("format") + uptime := int64(time.Since(processStartTime).Seconds()) + + if strings.Contains(accept, "application/json") || format == "json" { + c.JSON(http.StatusOK, gin.H{ + "uptime_seconds": uptime, + "http_requests_total": t.RequestsTotal.Load(), + "http_errors_total": t.ErrorsTotal.Load(), + "http_status_2xx_total": t.Status2xx.Load(), + "http_status_4xx_total": t.Status4xx.Load(), + "http_status_5xx_total": t.Status5xx.Load(), + "http_active_requests": t.ActiveRequests.Load(), + "http_duration_sum_ms": t.DurationSumMs.Load(), + }) + return + } + + c.Header("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + var sb strings.Builder + + sb.WriteString("# HELP app_uptime_seconds Total service uptime in seconds\n") + sb.WriteString("# TYPE app_uptime_seconds gauge\n") + fmt.Fprintf(&sb, "app_uptime_seconds %d\n\n", uptime) + + sb.WriteString("# HELP http_requests_total Total number of HTTP requests processed\n") + sb.WriteString("# TYPE http_requests_total counter\n") + fmt.Fprintf(&sb, "http_requests_total %d\n\n", t.RequestsTotal.Load()) + + sb.WriteString("# HELP http_errors_total Total number of failed HTTP requests\n") + sb.WriteString("# TYPE http_errors_total counter\n") + fmt.Fprintf(&sb, "http_errors_total %d\n\n", t.ErrorsTotal.Load()) + + sb.WriteString("# HELP http_status_total HTTP responses partitioned by status class\n") + sb.WriteString("# TYPE http_status_total counter\n") + fmt.Fprintf(&sb, "http_status_total{class=\"2xx\"} %d\n", t.Status2xx.Load()) + fmt.Fprintf(&sb, "http_status_total{class=\"4xx\"} %d\n", t.Status4xx.Load()) + fmt.Fprintf(&sb, "http_status_total{class=\"5xx\"} %d\n\n", t.Status5xx.Load()) + + sb.WriteString("# HELP http_active_requests Current number of in-flight HTTP requests\n") + sb.WriteString("# TYPE http_active_requests gauge\n") + fmt.Fprintf(&sb, "http_active_requests %d\n\n", t.ActiveRequests.Load()) + + sb.WriteString("# HELP http_duration_sum_ms Total request processing time in milliseconds\n") + sb.WriteString("# TYPE http_duration_sum_ms counter\n") + fmt.Fprintf(&sb, "http_duration_sum_ms %d\n", t.DurationSumMs.Load()) + + c.String(http.StatusOK, sb.String()) + } +} diff --git a/ginmw/red_metrics_test.go b/ginmw/red_metrics_test.go new file mode 100644 index 0000000..12d763d --- /dev/null +++ b/ginmw/red_metrics_test.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT + +package ginmw_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/umesh0492/go-libs/ginmw" +) + +func TestREDMetrics_Tracking(t *testing.T) { + tracker := &ginmw.MetricsTracker{} + r := gin.New() + r.Use(ginmw.REDMetrics(tracker)) + + r.GET("/ok", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + r.GET("/bad", func(c *gin.Context) { + c.String(http.StatusBadRequest, "bad") + }) + r.GET("/err", func(c *gin.Context) { + c.String(http.StatusInternalServerError, "err") + }) + r.GET("/metrics", ginmw.MetricsEndpoint(tracker)) + + // Send requests + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ok", nil)) + + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/bad", nil)) + + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/err", nil)) + + if tracker.RequestsTotal.Load() != 3 { + t.Fatalf("expected 3 total requests, got: %d", tracker.RequestsTotal.Load()) + } + if tracker.Status2xx.Load() != 1 { + t.Fatalf("expected 1 2xx request, got: %d", tracker.Status2xx.Load()) + } + if tracker.Status4xx.Load() != 1 { + t.Fatalf("expected 1 4xx request, got: %d", tracker.Status4xx.Load()) + } + if tracker.Status5xx.Load() != 1 { + t.Fatalf("expected 1 5xx request, got: %d", tracker.Status5xx.Load()) + } + if tracker.ErrorsTotal.Load() != 2 { + t.Fatalf("expected 2 total errors, got: %d", tracker.ErrorsTotal.Load()) + } + + // Test Prometheus text exposition + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 from /metrics, got: %d", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, "http_requests_total") || !strings.Contains(body, "app_uptime_seconds") { + t.Fatalf("expected prometheus metrics format, got: %s", body) + } + + // Test JSON exposition + w = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/metrics?format=json", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 from /metrics?format=json, got: %d", w.Code) + } + var jsonResp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &jsonResp); err != nil { + t.Fatalf("failed to decode JSON metrics: %v", err) + } + if _, ok := jsonResp["http_requests_total"]; !ok { + t.Fatalf("missing http_requests_total in JSON: %+v", jsonResp) + } +} diff --git a/ginmw/response.go b/ginmw/response.go new file mode 100644 index 0000000..d9e27e3 --- /dev/null +++ b/ginmw/response.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT + +package ginmw + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Envelope defines the standardized envelope format for API responses. +type Envelope[T any] struct { + Success bool `json:"success"` + Data T `json:"data,omitempty"` + Error *APIError `json:"error,omitempty"` +} + +// Response is a convenience alias for untyped or dynamic envelopes. +type Response = Envelope[any] + +// APIError represents structured error information in API response envelopes. +type APIError struct { + Code string `json:"code"` + Message string `json:"message"` + Detail string `json:"detail,omitempty"` +} + +// RespondSuccess sends an HTTP 200 with the success envelope. +func RespondSuccess(c *gin.Context, data any) { + c.JSON(http.StatusOK, Response{ + Success: true, + Data: data, + }) +} + +// RespondCreated sends an HTTP 201 with the success envelope. +func RespondCreated(c *gin.Context, data any) { + c.JSON(http.StatusCreated, Response{ + Success: true, + Data: data, + }) +} + +// RespondError sends the specified HTTP status code with a structured error envelope. +func RespondError(c *gin.Context, status int, code, message string) { + c.AbortWithStatusJSON(status, Response{ + Success: false, + Error: &APIError{ + Code: code, + Message: message, + }, + }) +} + +// RespondErrorWithDetail sends an error envelope with additional debugging detail. +func RespondErrorWithDetail(c *gin.Context, status int, code, message, detail string) { + c.AbortWithStatusJSON(status, Response{ + Success: false, + Error: &APIError{ + Code: code, + Message: message, + Detail: detail, + }, + }) +} diff --git a/ginmw/response_test.go b/ginmw/response_test.go new file mode 100644 index 0000000..b114d38 --- /dev/null +++ b/ginmw/response_test.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT + +package ginmw_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/umesh0492/go-libs/ginmw" +) + +func TestRespondSuccess(t *testing.T) { + r := gin.New() + r.GET("/ok", func(c *gin.Context) { + ginmw.RespondSuccess(c, map[string]string{"message": "all good"}) + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/ok", nil) + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got: %d", rec.Code) + } + + var resp ginmw.Response + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + if !resp.Success || resp.Data == nil { + t.Fatalf("unexpected envelope: %+v", resp) + } +} + +func TestRespondCreated(t *testing.T) { + r := gin.New() + r.POST("/create", func(c *gin.Context) { + ginmw.RespondCreated(c, "id-123") + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/create", nil) + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got: %d", rec.Code) + } +} + +func TestRespondError(t *testing.T) { + r := gin.New() + r.GET("/err", func(c *gin.Context) { + ginmw.RespondError(c, http.StatusNotFound, "NOT_FOUND", "Resource missing") + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/err", nil) + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got: %d", rec.Code) + } + + var resp ginmw.Response + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + if resp.Success || resp.Error == nil || resp.Error.Code != "NOT_FOUND" { + t.Fatalf("unexpected error response: %+v", resp) + } +} + +func TestRespondErrorWithDetail(t *testing.T) { + r := gin.New() + r.GET("/err-detail", func(c *gin.Context) { + ginmw.RespondErrorWithDetail(c, http.StatusBadRequest, "INVALID_PARAM", "Validation error", "Field 'email' is invalid") + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/err-detail", nil) + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got: %d", rec.Code) + } + + var resp ginmw.Response + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + if resp.Error == nil || resp.Error.Detail != "Field 'email' is invalid" { + t.Fatalf("expected detail, got: %+v", resp.Error) + } +} diff --git a/ginmw/tenant.go b/ginmw/tenant.go new file mode 100644 index 0000000..ee92d78 --- /dev/null +++ b/ginmw/tenant.go @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT + +package ginmw + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// Default tenant extraction constants. +const ( + DefaultTenantContextKey = "tenant_id" + DefaultTenantHeader = "x-tenant-id" + DefaultTenantQuery = "tenant_id" +) + +type tenantOptions struct { + contextKey string + headerName string + queryParam string + bypassRoles map[string]bool + errorMessage string +} + +// TenantOption configures RequireTenant middleware behavior. +type TenantOption func(*tenantOptions) + +// WithTenantContextKey sets the context key used to store/retrieve the tenant ID. +func WithTenantContextKey(key string) TenantOption { + return func(o *tenantOptions) { + o.contextKey = key + } +} + +// WithTenantHeader sets the HTTP request header used for tenant ID extraction. +func WithTenantHeader(header string) TenantOption { + return func(o *tenantOptions) { + o.headerName = header + } +} + +// WithTenantQuery sets the URL query parameter used for tenant ID extraction. +func WithTenantQuery(param string) TenantOption { + return func(o *tenantOptions) { + o.queryParam = param + } +} + +// WithBypassRoles defines roles that are permitted to bypass the mandatory tenant check +// (e.g. cross-tenant platform administrators). +func WithBypassRoles(roles ...string) TenantOption { + return func(o *tenantOptions) { + for _, r := range roles { + o.bypassRoles[strings.ToUpper(strings.TrimSpace(r))] = true + } + } +} + +// RequireTenant ensures that the incoming request has a non-empty tenant identifier. +// The tenant ID is resolved from: +// 1. Existing Gin context key (e.g. set by prior auth middleware) +// 2. JWT claims (if present in context) +// 3. HTTP header (defaults to "x-tenant-id") +// 4. URL query parameter (defaults to "tenant_id") +// +// If the tenant ID is missing and the user does not hold a bypass role, it aborts +// with HTTP 400 Bad Request and code "TENANT_REQUIRED". +func RequireTenant(opts ...TenantOption) gin.HandlerFunc { + cfg := &tenantOptions{ + contextKey: DefaultTenantContextKey, + headerName: DefaultTenantHeader, + queryParam: DefaultTenantQuery, + bypassRoles: make(map[string]bool), + errorMessage: "Tenant ID required via header (" + DefaultTenantHeader + ") or query parameter (" + DefaultTenantQuery + ")", + } + for _, opt := range opts { + if opt != nil { + opt(cfg) + } + } + + return func(c *gin.Context) { + tenantID := ExtractTenantID(c, cfg.contextKey, cfg.headerName, cfg.queryParam) + + if tenantID == "" { + // Check if caller holds an allowed bypass role + if claims := GetClaims(c); claims != nil { + for _, r := range claims.Roles { + if cfg.bypassRoles[strings.ToUpper(strings.TrimSpace(r))] { + c.Next() + return + } + } + } + + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "success": false, + "error": gin.H{ + "code": "TENANT_REQUIRED", + "message": cfg.errorMessage, + }, + }) + return + } + + c.Set(cfg.contextKey, tenantID) + c.Next() + } +} + +// ExtractTenantID retrieves the active tenant identifier from context, claims, headers, or query. +func ExtractTenantID(c *gin.Context, contextKey, headerName, queryParam string) string { + if contextKey == "" { + contextKey = DefaultTenantContextKey + } + if headerName == "" { + headerName = DefaultTenantHeader + } + if queryParam == "" { + queryParam = DefaultTenantQuery + } + + // 1. Direct Gin context lookup + if val, exists := c.Get(contextKey); exists { + if s, ok := val.(string); ok && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) + } + } + + // 2. JWT claims lookup + if claims := GetClaims(c); claims != nil && claims.TenantID.String() != "00000000-0000-0000-0000-000000000000" && claims.TenantID.String() != "" { + return claims.TenantID.String() + } + + // 3. Request Header lookup + if h := strings.TrimSpace(c.GetHeader(headerName)); h != "" { + return h + } + + // 4. URL query lookup + return strings.TrimSpace(c.Query(queryParam)) +} diff --git a/ginmw/tenant_test.go b/ginmw/tenant_test.go new file mode 100644 index 0000000..7e132bc --- /dev/null +++ b/ginmw/tenant_test.go @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MIT + +package ginmw_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/umesh0492/go-libs/ginmw" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func TestRequireTenant_FromHeader(t *testing.T) { + r := gin.New() + r.Use(ginmw.RequireTenant()) + r.GET("/test", func(c *gin.Context) { + tenantID := c.GetString("tenant_id") + c.String(http.StatusOK, tenantID) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("x-tenant-id", "tenant-123") + rec := httptest.NewRecorder() + + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got: %d", rec.Code) + } + if rec.Body.String() != "tenant-123" { + t.Fatalf("expected 'tenant-123', got: %s", rec.Body.String()) + } +} + +func TestRequireTenant_FromQuery(t *testing.T) { + r := gin.New() + r.Use(ginmw.RequireTenant()) + r.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/test?tenant_id=tenant-abc", nil) + rec := httptest.NewRecorder() + + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got: %d", rec.Code) + } +} + +func TestRequireTenant_Missing_Aborts(t *testing.T) { + r := gin.New() + r.Use(ginmw.RequireTenant()) + r.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + rec := httptest.NewRecorder() + + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got: %d", rec.Code) + } +} + +func TestRequireTenant_BypassRole(t *testing.T) { + r := gin.New() + // Middleware injecting super admin claims + r.Use(func(c *gin.Context) { + c.Set("claims", &ginmw.Claims{ + UserID: uuid.New(), + Roles: []string{"SUPERADMIN"}, + }) + c.Next() + }) + r.Use(ginmw.RequireTenant(ginmw.WithBypassRoles("SUPERADMIN"))) + r.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "bypassed") + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + rec := httptest.NewRecorder() + + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for bypassed superadmin, got: %d", rec.Code) + } + if rec.Body.String() != "bypassed" { + t.Fatalf("expected body 'bypassed', got: %s", rec.Body.String()) + } +} + +func TestRequireTenant_CustomOptionsAndContext(t *testing.T) { + r := gin.New() + r.Use(ginmw.RequireTenant( + ginmw.WithTenantContextKey("custom_tenant_key"), + ginmw.WithTenantHeader("x-custom-tenant"), + ginmw.WithTenantQuery("custom_tid"), + )) + r.GET("/custom", func(c *gin.Context) { + val := c.GetString("custom_tenant_key") + c.String(http.StatusOK, val) + }) + + // Test custom header + req := httptest.NewRequest(http.MethodGet, "/custom", nil) + req.Header.Set("x-custom-tenant", "cust-1") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || rec.Body.String() != "cust-1" { + t.Fatalf("expected cust-1 from custom header, got %d %s", rec.Code, rec.Body.String()) + } + + // Test custom query + req = httptest.NewRequest(http.MethodGet, "/custom?custom_tid=cust-2", nil) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || rec.Body.String() != "cust-2" { + t.Fatalf("expected cust-2 from custom query, got %d %s", rec.Code, rec.Body.String()) + } +} + +func TestExtractTenantID_ContextAndClaims(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodGet, "/", nil) + c.Request = req + + // 1. Context key lookup + c.Set(ginmw.DefaultTenantContextKey, "ctx-tenant") + if tid := ginmw.ExtractTenantID(c, "", "", ""); tid != "ctx-tenant" { + t.Fatalf("expected ctx-tenant, got %s", tid) + } + + // 2. Claims lookup + c.Set(ginmw.DefaultTenantContextKey, "") + claimsID := uuid.New() + c.Set("claims", &ginmw.Claims{ + TenantID: claimsID, + }) + if tid := ginmw.ExtractTenantID(c, "", "", ""); tid != claimsID.String() { + t.Fatalf("expected %s from claims, got %s", claimsID.String(), tid) + } +} diff --git a/jwks/README.md b/jwks/README.md new file mode 100644 index 0000000..a8ace70 --- /dev/null +++ b/jwks/README.md @@ -0,0 +1,12 @@ +# jwks + +Package `jwks` provides an in-memory cached JSON Web Key Set (JWKS) resolver for cryptographic public keys, featuring automatic TTL refresh and resilient stale fallback during network interruptions. + +## Usage + +```go +import "github.com/umesh0492/go-libs/jwks" + +// Retrieve ECDSA P-256 public key by KID +pubKey, err := jwks.GetECPublicKey("my-kid", "https://auth.example.com/.well-known/jwks.json") +``` diff --git a/jwks/doc.go b/jwks/doc.go new file mode 100644 index 0000000..eb65bf9 --- /dev/null +++ b/jwks/doc.go @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MIT + +// Package jwks provides an in-memory cached JSON Web Key Set (JWKS) resolver +// for cryptographic public keys, with automatic TTL refresh and resilient fallback. +package jwks diff --git a/jwks/jwks.go b/jwks/jwks.go new file mode 100644 index 0000000..164d8dd --- /dev/null +++ b/jwks/jwks.go @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT + +package jwks + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net/http" + "os" + "strings" + "sync" + "time" +) + +// Key represents a single cryptographic key entry in a JWKS document. +type Key struct { + KID string `json:"kid"` + KTY string `json:"kty"` + Alg string `json:"alg"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +// Set represents a collection of JWKS keys. +type Set struct { + Keys []Key `json:"keys"` +} + +// Resolver manages in-memory caching and retrieval of public keys from a JWKS URL. +type Resolver struct { + mu sync.RWMutex + cache map[string]*ecdsa.PublicKey + lastFetch time.Time + cacheTTL time.Duration + httpClient *http.Client + defaultJWKSURL string +} + +// DefaultResolver is the package-level default JWKS resolver instance. +var DefaultResolver = NewResolver() + +// NewResolver creates a new JWKS public key resolver with sensible defaults. +func NewResolver() *Resolver { + return &Resolver{ + cache: make(map[string]*ecdsa.PublicKey), + cacheTTL: 2 * time.Hour, + httpClient: &http.Client{Timeout: 5 * time.Second}, + } +} + +// GetECPublicKey retrieves or caches the ECDSA P-256 public key for the specified key ID (kid). +func (r *Resolver) GetECPublicKey(kid, jwksURL string) (*ecdsa.PublicKey, error) { + if key := r.getCachedKey(kid); key != nil { + return key, nil + } + + r.mu.Lock() + defer r.mu.Unlock() + + // Double check under write lock + if key, exists := r.cache[kid]; exists && time.Since(r.lastFetch) < r.cacheTTL { + return key, nil + } + + targetURL, err := r.resolveJWKSURL(jwksURL) + if err != nil { + return nil, err + } + + if err := r.fetchAndCacheKeys(targetURL); err != nil { + if cachedKey := r.cache[kid]; cachedKey != nil { + return cachedKey, nil // Fallback to stale cached key if network is temporarily unreachable + } + return nil, err + } + + if found, ok := r.cache[kid]; ok { + return found, nil + } + // Fallback to first available P-256 EC key if kid was omitted or changed + for _, k := range r.cache { + return k, nil + } + return nil, fmt.Errorf("public key with kid '%s' not found in JWKS", kid) +} + +func (r *Resolver) getCachedKey(kid string) *ecdsa.PublicKey { + r.mu.RLock() + defer r.mu.RUnlock() + if key, exists := r.cache[kid]; exists && time.Since(r.lastFetch) < r.cacheTTL { + return key + } + return nil +} + +func (r *Resolver) resolveJWKSURL(jwksURL string) (string, error) { + if jwksURL != "" { + return jwksURL, nil + } + if r.defaultJWKSURL != "" { + return r.defaultJWKSURL, nil + } + if envURL := os.Getenv("AUTH_JWKS_URL"); envURL != "" { + return strings.TrimSpace(envURL), nil + } + if envBase := os.Getenv("AUTH_ISSUER_URL"); envBase != "" { + return fmt.Sprintf("%s/.well-known/jwks.json", strings.TrimSuffix(strings.TrimSpace(envBase), "/")), nil + } + return "", fmt.Errorf("failed to fetch JWKS: no jwksURL provided and AUTH_JWKS_URL/AUTH_ISSUER_URL not set") +} + +func (r *Resolver) fetchAndCacheKeys(targetURL string) error { + //nolint:gosec // G704: targetURL is an operator-configured endpoint for JWKS public keys + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, targetURL, http.NoBody) + if err != nil { + return fmt.Errorf("failed to create JWKS request: %w", err) + } + resp, err := r.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch JWKS from %s: %w", targetURL, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to fetch JWKS: received HTTP %d from %s", resp.StatusCode, targetURL) + } + + var set Set + if err := json.NewDecoder(resp.Body).Decode(&set); err != nil { + return fmt.Errorf("failed to decode JWKS payload: %w", err) + } + + for _, k := range set.Keys { + if k.KTY == "EC" && k.Crv == "P-256" { + if pubKey := parseECP256Key(k); pubKey != nil { + r.cache[k.KID] = pubKey + } + } + } + r.lastFetch = time.Now() + return nil +} + +func parseECP256Key(k Key) *ecdsa.PublicKey { + xBytes, errX := base64.RawURLEncoding.DecodeString(k.X) + yBytes, errY := base64.RawURLEncoding.DecodeString(k.Y) + if errX != nil || errY != nil { + return nil + } + //nolint:staticcheck // SA1019: JWKS standard defines EC public keys via raw big-endian coordinates + return &ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: new(big.Int).SetBytes(xBytes), + Y: new(big.Int).SetBytes(yBytes), + } +} + +// GetECPublicKey fetches from the DefaultResolver. +func GetECPublicKey(kid, jwksURL string) (*ecdsa.PublicKey, error) { + return DefaultResolver.GetECPublicKey(kid, jwksURL) +} diff --git a/jwks/jwks_test.go b/jwks/jwks_test.go new file mode 100644 index 0000000..02c7ef2 --- /dev/null +++ b/jwks/jwks_test.go @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MIT + +package jwks_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/umesh0492/go-libs/jwks" +) + +func testExtractXY(t *testing.T, pub *ecdsa.PublicKey) (string, string) { + t.Helper() + ecdhKey, err := pub.ECDH() + if err != nil { + t.Fatalf("failed to get ECDH key: %v", err) + } + b := ecdhKey.Bytes() + x := base64.RawURLEncoding.EncodeToString(b[1:33]) + y := base64.RawURLEncoding.EncodeToString(b[33:65]) + return x, y +} + +func TestJWKS_GetECPublicKey(t *testing.T) { + // Generate test ECDSA key + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("failed to generate test ecdsa key: %v", err) + } + + xStr, yStr := testExtractXY(t, &privKey.PublicKey) + + mockJWKS := jwks.Set{ + Keys: []jwks.Key{ + { + KID: "test-kid-1", + KTY: "EC", + Alg: "ES256", + Crv: "P-256", + X: xStr, + Y: yStr, + }, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(mockJWKS) + })) + defer server.Close() + + resolver := jwks.NewResolver() + pubKey, err := resolver.GetECPublicKey("test-kid-1", server.URL) + if err != nil { + t.Fatalf("failed to get public key: %v", err) + } + + if !pubKey.Equal(&privKey.PublicKey) { + t.Fatal("retrieved public key does not match expected") + } + + // Verify caching (second call doesn't error even if server is closed) + server.Close() + cachedKey, err := resolver.GetECPublicKey("test-kid-1", server.URL) + if err != nil { + t.Fatalf("expected cached key, got error: %v", err) + } + if !cachedKey.Equal(pubKey) { + t.Fatal("cached key mismatch") + } +} + +func TestJWKS_EnvFallbacks(t *testing.T) { + privKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + xStr, yStr := testExtractXY(t, &privKey.PublicKey) + mockJWKS := jwks.Set{ + Keys: []jwks.Key{ + { + KID: "env-kid", + KTY: "EC", + Alg: "ES256", + Crv: "P-256", + X: xStr, + Y: yStr, + }, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(mockJWKS) + })) + defer server.Close() + + // 1. AUTH_JWKS_URL + os.Setenv("AUTH_JWKS_URL", server.URL) + defer os.Unsetenv("AUTH_JWKS_URL") + + resolver := jwks.NewResolver() + key, err := resolver.GetECPublicKey("env-kid", "") + if err != nil || key == nil { + t.Fatalf("expected key resolved from AUTH_JWKS_URL, got err: %v", err) + } + + // 2. AUTH_ISSUER_URL + os.Unsetenv("AUTH_JWKS_URL") + os.Setenv("AUTH_ISSUER_URL", server.URL) + defer os.Unsetenv("AUTH_ISSUER_URL") + + resolver2 := jwks.NewResolver() + key2, err2 := resolver2.GetECPublicKey("env-kid", "") + if err2 != nil || key2 == nil { + t.Fatalf("expected key resolved from AUTH_ISSUER_URL, got err: %v", err2) + } + + // 3. No URL and no env vars + os.Unsetenv("AUTH_ISSUER_URL") + resolver3 := jwks.NewResolver() + _, err3 := resolver3.GetECPublicKey("env-kid", "") + if err3 == nil { + t.Fatal("expected error when no URL and no env vars set") + } +} + +func TestJWKS_ErrorCases(t *testing.T) { + // 500 Internal Server Error + server500 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server500.Close() + + resolver := jwks.NewResolver() + _, err := resolver.GetECPublicKey("kid-1", server500.URL) + if err == nil { + t.Fatal("expected error for HTTP 500 response") + } + + // Malformed JSON + serverBadJSON := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{invalid-json")) + })) + defer serverBadJSON.Close() + + _, err = resolver.GetECPublicKey("kid-1", serverBadJSON.URL) + if err == nil { + t.Fatal("expected error for malformed JSON") + } + + // Empty keys + serverEmpty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(jwks.Set{}) + })) + defer serverEmpty.Close() + + _, err = resolver.GetECPublicKey("kid-missing", serverEmpty.URL) + if err == nil { + t.Fatal("expected error when key not found in JWKS") + } + + // Invalid URL + _, err = resolver.GetECPublicKey("kid-1", "http://[::1]:namedport") + if err == nil { + t.Fatal("expected error for invalid URL") + } +} + +func TestJWKS_PackageLevelHelper(t *testing.T) { + privKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + xStr, yStr := testExtractXY(t, &privKey.PublicKey) + mockJWKS := jwks.Set{ + Keys: []jwks.Key{ + { + KID: "pkg-kid", + KTY: "EC", + Alg: "ES256", + Crv: "P-256", + X: xStr, + Y: yStr, + }, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(mockJWKS) + })) + defer server.Close() + + key, err := jwks.GetECPublicKey("pkg-kid", server.URL) + if err != nil || key == nil { + t.Fatalf("expected package-level GetECPublicKey to succeed, got: %v", err) + } +} diff --git a/pagination/slice.go b/pagination/slice.go new file mode 100644 index 0000000..f514677 --- /dev/null +++ b/pagination/slice.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT + +package pagination + +// PageMeta provides standardized pagination metadata for paginated API responses. +type PageMeta struct { + Page int `json:"page"` + Limit int `json:"limit"` + Total int `json:"total"` + TotalPages int `json:"total_pages"` + HasMore bool `json:"has_more"` +} + +// PaginateSlice safely bounds and slices any generic slice [T any], +// eliminating duplicate slice slicing and fixing the silent overflow bug +// where requesting out-of-range pages returned the entire unfiltered dataset. +func PaginateSlice[T any](items []T, page, limit int) ([]T, PageMeta) { + total := len(items) + if limit <= 0 { + return items, PageMeta{ + Page: 1, + Limit: total, + Total: total, + TotalPages: 1, + HasMore: false, + } + } + if page <= 0 { + page = 1 + } + + totalPages := (total + limit - 1) / limit + if totalPages == 0 { + totalPages = 1 + } + + start := (page - 1) * limit + if start >= total { + return []T{}, PageMeta{ + Page: page, + Limit: limit, + Total: total, + TotalPages: totalPages, + HasMore: false, + } + } + + end := start + limit + if end > total { + end = total + } + + return items[start:end], PageMeta{ + Page: page, + Limit: limit, + Total: total, + TotalPages: totalPages, + HasMore: end < total, + } +} diff --git a/pagination/slice_test.go b/pagination/slice_test.go new file mode 100644 index 0000000..9c9748e --- /dev/null +++ b/pagination/slice_test.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT + +package pagination_test + +import ( + "testing" + + "github.com/umesh0492/go-libs/pagination" +) + +func TestPaginateSlice(t *testing.T) { + data := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + + // First page of 3 + p1, meta1 := pagination.PaginateSlice(data, 1, 3) + if len(p1) != 3 || p1[0] != 1 || p1[2] != 3 { + t.Fatalf("unexpected p1: %v", p1) + } + if meta1.Total != 10 || meta1.TotalPages != 4 || !meta1.HasMore { + t.Fatalf("unexpected meta1: %+v", meta1) + } + + // Last page + p4, meta4 := pagination.PaginateSlice(data, 4, 3) + if len(p4) != 1 || p4[0] != 10 { + t.Fatalf("unexpected p4: %v", p4) + } + if meta4.HasMore { + t.Fatal("expected HasMore to be false on last page") + } + + // Out of bounds page + p5, meta5 := pagination.PaginateSlice(data, 5, 3) + if len(p5) != 0 { + t.Fatalf("expected empty slice for out of bounds page, got: %v", p5) + } + if meta5.HasMore { + t.Fatal("expected HasMore false for out of bounds page") + } + + // Empty slice + pEmpty, metaEmpty := pagination.PaginateSlice([]string{}, 1, 10) + if len(pEmpty) != 0 || metaEmpty.Total != 0 || metaEmpty.HasMore { + t.Fatalf("unexpected empty pagination: %v, %+v", pEmpty, metaEmpty) + } + + // Negative / zero limit + pAll, metaAll := pagination.PaginateSlice(data, 1, 0) + if len(pAll) != 10 || metaAll.Total != 10 { + t.Fatalf("expected all items when limit <= 0, got: %v", pAll) + } +} diff --git a/scripts/check_version.sh b/scripts/check_version.sh index b5328a3..2e6e6b0 100755 --- a/scripts/check_version.sh +++ b/scripts/check_version.sh @@ -59,7 +59,7 @@ echo "========================================================" echo "📦 Verifying Package Inventory & Count Synchronization" echo "========================================================" -EXPECTED_PKG_COUNT=31 +EXPECTED_PKG_COUNT=35 MEASURED_PKGS=$(go list ./... | grep -vE '^github\.com/umesh0492/go-libs$|/(loadgen|tests?)$' | wc -l | tr -d ' ') README_PKG_COUNT=$(grep -oE '[0-9]+ packages' README.md | awk '{print $1}' | head -n1 || true) TABLE_PKG_COUNT=$(sed -n '/| Package | Purpose | Statement Coverage |/,/| \*\*Total Statement Coverage\*\*/p' README.md | grep -E '^\| `[a-zA-Z0-9_/]+` \|' | wc -l | tr -d ' ') diff --git a/uuidutil/README.md b/uuidutil/README.md new file mode 100644 index 0000000..c0874bc --- /dev/null +++ b/uuidutil/README.md @@ -0,0 +1,17 @@ +# uuidutil + +Package `uuidutil` provides domain-agnostic UUID generation, parsing, pointer transformation, and validation helpers wrapping `github.com/google/uuid`. + +## Usage + +```go +import "github.com/umesh0492/go-libs/uuidutil" + +id := uuidutil.New() +str := uuidutil.NewString() + +ptr, err := uuidutil.ParsePtr(optionalIDString) +if uuidutil.IsValid(str) { + // Valid UUID +} +``` diff --git a/uuidutil/doc.go b/uuidutil/doc.go new file mode 100644 index 0000000..776b729 --- /dev/null +++ b/uuidutil/doc.go @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: MIT + +// Package uuidutil provides domain-agnostic UUID generation, parsing, and validation helpers. +package uuidutil diff --git a/uuidutil/uuidutil.go b/uuidutil/uuidutil.go new file mode 100644 index 0000000..a5387cc --- /dev/null +++ b/uuidutil/uuidutil.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT + +package uuidutil + +import ( + "strings" + + "github.com/google/uuid" +) + +// UUID is an alias for uuid.UUID. +type UUID = uuid.UUID + +// Nil is the zero UUID value. +var Nil = uuid.Nil + +// New generates a new random v4 UUID. +func New() UUID { + return uuid.New() +} + +// NewString generates a new random v4 UUID string. +func NewString() string { + return uuid.New().String() +} + +// Parse parses a UUID string, returning an error on failure. +func Parse(s string) (UUID, error) { + return uuid.Parse(s) +} + +// ParsePtr parses a UUID string, returning a pointer to the UUID or nil if empty. +func ParsePtr(s string) (*UUID, error) { + if strings.TrimSpace(s) == "" { + return nil, nil + } + u, err := uuid.Parse(s) + if err != nil { + return nil, err + } + return &u, nil +} + +// MustParse parses a UUID string and panics on failure. +func MustParse(s string) UUID { + return uuid.MustParse(s) +} + +// IsValid reports whether s is a valid UUID string. +func IsValid(s string) bool { + _, err := uuid.Parse(s) + return err == nil +} + +// IsNil reports whether the UUID is the zero value. +func IsNil(id UUID) bool { + return id == uuid.Nil +} diff --git a/uuidutil/uuidutil_test.go b/uuidutil/uuidutil_test.go new file mode 100644 index 0000000..590cf8f --- /dev/null +++ b/uuidutil/uuidutil_test.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT + +package uuidutil_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/umesh0492/go-libs/uuidutil" +) + +func TestUUIDUtil_NewAndIsValid(t *testing.T) { + id := uuidutil.New() + if uuidutil.IsNil(id) { + t.Fatal("expected non-nil UUID from New()") + } + + str := uuidutil.NewString() + if !uuidutil.IsValid(str) { + t.Fatalf("expected valid UUID string from NewString(), got: %s", str) + } + + if uuidutil.IsValid("not-a-valid-uuid") { + t.Fatal("expected IsValid to return false for invalid string") + } +} + +func TestUUIDUtil_Parse(t *testing.T) { + raw := uuid.New().String() + + parsed, err := uuidutil.Parse(raw) + if err != nil { + t.Fatalf("unexpected error parsing valid UUID: %v", err) + } + if parsed.String() != raw { + t.Fatalf("expected parsed %s, got %s", raw, parsed.String()) + } + + _, err = uuidutil.Parse("invalid") + if err == nil { + t.Fatal("expected error parsing invalid UUID string") + } +} + +func TestUUIDUtil_ParsePtr(t *testing.T) { + ptr, err := uuidutil.ParsePtr("") + if err != nil || ptr != nil { + t.Fatalf("expected nil ptr and nil error for empty string, got ptr: %v, err: %v", ptr, err) + } + + raw := uuid.New().String() + ptr, err = uuidutil.ParsePtr(raw) + if err != nil || ptr == nil { + t.Fatalf("unexpected error parsing valid UUID ptr: %v", err) + } + if ptr.String() != raw { + t.Fatalf("expected %s, got %s", raw, ptr.String()) + } +} + +func TestUUIDUtil_MustParse(t *testing.T) { + raw := uuid.New().String() + id := uuidutil.MustParse(raw) + if id.String() != raw { + t.Fatalf("expected %s, got %s", raw, id.String()) + } + + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic from MustParse with invalid input") + } + }() + uuidutil.MustParse("invalid") +}