diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 914ac38f..09c838d1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -43,7 +43,7 @@ This is the Modular Go framework - a structured way to create modular applicatio ## Development Workflow ### Local Development Setup -1. Clone the repository: `git clone https://github.com/CrisisTextLine/modular.git` +1. Clone the repository: `git clone https://github.com/GoCodeAlone/modular.git` 2. Install Go 1.23.0 or later (toolchain uses 1.24.2) 3. Install golangci-lint: `go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest` 4. Run tests to verify setup: `go test ./... -v` @@ -153,7 +153,7 @@ Working example applications: ### CLI Tool (`modcli`) - Generate new modules: `modcli generate module --name MyModule` - Generate configurations: `modcli generate config --name MyConfig` -- Install with: `go install github.com/CrisisTextLine/modular/cmd/modcli@latest` +- Install with: `go install github.com/GoCodeAlone/modular/cmd/modcli@latest` ### Debugging Tools - Debug module interfaces: `modular.DebugModuleInterfaces(app, "module-name")` diff --git a/.github/workflows/auto-bump-modules.yml b/.github/workflows/auto-bump-modules.yml index 4dd2b470..a0f52f6a 100644 --- a/.github/workflows/auto-bump-modules.yml +++ b/.github/workflows/auto-bump-modules.yml @@ -63,12 +63,12 @@ jobs: [ -f "$mod" ] || continue dir=$(dirname "$mod") # If the require line exists with a version different from CORE, update via go mod edit (portable, avoids sed incompat) - if grep -q "github.com/CrisisTextLine/modular v" "$mod" && ! grep -q "github.com/CrisisTextLine/modular ${CORE}" "$mod"; then - (cd "$dir" && go mod edit -require=github.com/CrisisTextLine/modular@${CORE}) + if grep -q "github.com/GoCodeAlone/modular v" "$mod" && ! grep -q "github.com/GoCodeAlone/modular ${CORE}" "$mod"; then + (cd "$dir" && go mod edit -require=github.com/GoCodeAlone/modular@${CORE}) UPDATED=1 fi # Drop any replace directive pointing to local modular path to avoid accidental pinning - (cd "$dir" && go mod edit -dropreplace=github.com/CrisisTextLine/modular 2>/dev/null || true) + (cd "$dir" && go mod edit -dropreplace=github.com/GoCodeAlone/modular 2>/dev/null || true) done if [ "$UPDATED" = 0 ]; then echo "No module files needed updating" @@ -114,10 +114,10 @@ jobs: run: | set -euo pipefail CORE=${{ steps.ver.outputs.core_version }} - OLD=$(git grep -h -o 'github.com/CrisisTextLine/modular v[0-9]\+\.[0-9]\+\.[0-9]\+' -- '*.md' | grep -v $CORE | head -n1 | awk '{print $1}' || true) + OLD=$(git grep -h -o 'github.com/GoCodeAlone/modular v[0-9]\+\.[0-9]\+\.[0-9]\+' -- '*.md' | grep -v $CORE | head -n1 | awk '{print $1}' || true) # Replace any explicit old version with current in markdown examples if [ -n "$OLD" ]; then - find . -name '*.md' -print0 | xargs -0 sed -i "" -E "s#github.com/CrisisTextLine/modular v[0-9]+\.[0-9]+\.[0-9]+#github.com/CrisisTextLine/modular ${CORE}#g" || find . -name '*.md' -print0 | xargs -0 sed -i -E "s#github.com/CrisisTextLine/modular v[0-9]+\.[0-9]+\.[0-9]+#github.com/CrisisTextLine/modular ${CORE}#g" + find . -name '*.md' -print0 | xargs -0 sed -i "" -E "s#github.com/GoCodeAlone/modular v[0-9]+\.[0-9]+\.[0-9]+#github.com/GoCodeAlone/modular ${CORE}#g" || find . -name '*.md' -print0 | xargs -0 sed -i -E "s#github.com/GoCodeAlone/modular v[0-9]+\.[0-9]+\.[0-9]+#github.com/GoCodeAlone/modular ${CORE}#g" fi - name: Create PR diff --git a/.github/workflows/bdd-matrix.yml b/.github/workflows/bdd-matrix.yml index a4df8318..17dc404e 100644 --- a/.github/workflows/bdd-matrix.yml +++ b/.github/workflows/bdd-matrix.yml @@ -67,7 +67,7 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.0 pinned with: token: ${{ secrets.CODECOV_TOKEN }} - slug: CrisisTextLine/modular + slug: GoCodeAlone/modular files: core-bdd-coverage.txt flags: core-bdd - name: Persist core BDD coverage artifact @@ -127,7 +127,7 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.0 pinned with: token: ${{ secrets.CODECOV_TOKEN }} - slug: CrisisTextLine/modular + slug: GoCodeAlone/modular files: modules/${{ matrix.module }}/bdd-${{ matrix.module }}-coverage.txt flags: bdd-${{ matrix.module }} - name: Persist module BDD coverage artifact @@ -203,7 +203,7 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.0 pinned with: token: ${{ secrets.CODECOV_TOKEN }} - slug: CrisisTextLine/modular + slug: GoCodeAlone/modular files: merged-bdd-coverage.txt flags: merged-bdd - name: Persist merged BDD coverage artifact diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3512332..f26ff5ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.0 pinned with: token: ${{ secrets.CODECOV_TOKEN }} - slug: CrisisTextLine/modular + slug: GoCodeAlone/modular files: coverage.txt flags: unit - name: Upload unit coverage artifact @@ -94,7 +94,7 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.0 pinned with: token: ${{ secrets.CODECOV_TOKEN }} - slug: CrisisTextLine/modular + slug: GoCodeAlone/modular directory: cmd/modcli/ files: cli-coverage.txt flags: cli @@ -192,7 +192,7 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.0 pinned with: token: ${{ secrets.CODECOV_TOKEN }} - slug: CrisisTextLine/modular + slug: GoCodeAlone/modular files: total-coverage.txt flags: total fail_ci_if_error: true diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 389b9532..27dca2a5 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -168,7 +168,7 @@ jobs: - name: Build run: | cd cmd/modcli - go build -v -ldflags "-X github.com/CrisisTextLine/modular/cmd/modcli/cmd.Version=${{ needs.prepare.outputs.version }} -X github.com/CrisisTextLine/modular/cmd/modcli/cmd.Commit=${{ github.sha }} -X github.com/CrisisTextLine/modular/cmd/modcli/cmd.Date=$(date +'%Y-%m-%d')" -o ${{ matrix.artifact_name }} + go build -v -ldflags "-X github.com/GoCodeAlone/modular/cmd/modcli/cmd.Version=${{ needs.prepare.outputs.version }} -X github.com/GoCodeAlone/modular/cmd/modcli/cmd.Commit=${{ github.sha }} -X github.com/GoCodeAlone/modular/cmd/modcli/cmd.Date=$(date +'%Y-%m-%d')" -o ${{ matrix.artifact_name }} shell: bash - name: Upload artifact @@ -252,7 +252,7 @@ jobs: - name: Announce to Go proxy run: | VERSION="${{ needs.prepare.outputs.version }}" - MODULE_NAME="github.com/CrisisTextLine/modular/cmd/modcli" + MODULE_NAME="github.com/GoCodeAlone/modular/cmd/modcli" GOPROXY=proxy.golang.org go list -m ${MODULE_NAME}@${VERSION} diff --git a/.github/workflows/examples-ci.yml b/.github/workflows/examples-ci.yml index 0b20152e..16a485fe 100644 --- a/.github/workflows/examples-ci.yml +++ b/.github/workflows/examples-ci.yml @@ -451,7 +451,7 @@ jobs: # Check that replace directives point to correct paths if ! grep -q "replace.*=> ../../" go.mod; then echo "❌ Missing or incorrect replace directive in ${{ matrix.example }}/go.mod" - echo "Expected: replace github.com/CrisisTextLine/modular => ../../" + echo "Expected: replace github.com/GoCodeAlone/modular => ../../" cat go.mod exit 1 fi diff --git a/.github/workflows/module-release.yml b/.github/workflows/module-release.yml index 7e4dd14c..b7986bc6 100644 --- a/.github/workflows/module-release.yml +++ b/.github/workflows/module-release.yml @@ -482,9 +482,9 @@ jobs: # Construct correct module path with version suffix for v2+ if [ "$MAJOR_VERSION" -ge 2 ]; then - MODULE_NAME="github.com/CrisisTextLine/modular/modules/${MODULE}/v${MAJOR_VERSION}" + MODULE_NAME="github.com/GoCodeAlone/modular/modules/${MODULE}/v${MAJOR_VERSION}" else - MODULE_NAME="github.com/CrisisTextLine/modular/modules/${MODULE}" + MODULE_NAME="github.com/GoCodeAlone/modular/modules/${MODULE}" fi echo "Announcing ${MODULE_NAME}@${VERSION} to Go proxy..." diff --git a/.github/workflows/modules-ci.yml b/.github/workflows/modules-ci.yml index 87ea93bd..565c1404 100644 --- a/.github/workflows/modules-ci.yml +++ b/.github/workflows/modules-ci.yml @@ -137,7 +137,7 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.0 pinned with: token: ${{ secrets.CODECOV_TOKEN }} - slug: CrisisTextLine/modular + slug: GoCodeAlone/modular directory: modules/${{ matrix.module }}/ files: ${{ matrix.module }}-coverage.txt flags: ${{ matrix.module }} diff --git a/.github/workflows/release-all.yml b/.github/workflows/release-all.yml index de6aa896..308c45ac 100644 --- a/.github/workflows/release-all.yml +++ b/.github/workflows/release-all.yml @@ -333,9 +333,9 @@ jobs: # Construct correct module path with version suffix for v2+ if [ "$MAJOR_VERSION" -ge 2 ]; then - MODULE_NAME="github.com/CrisisTextLine/modular/v${MAJOR_VERSION}" + MODULE_NAME="github.com/GoCodeAlone/modular/v${MAJOR_VERSION}" else - MODULE_NAME="github.com/CrisisTextLine/modular" + MODULE_NAME="github.com/GoCodeAlone/modular" fi GOPROXY=proxy.golang.org go list -m ${MODULE_NAME}@${CURR} @@ -377,9 +377,9 @@ jobs: MAJOR_VERSION="${VER#v}" MAJOR_VERSION="${MAJOR_VERSION%%.*}" if [ "$MAJOR_VERSION" -ge 2 ]; then - MOD_PATH="github.com/CrisisTextLine/modular/modules/${M}/v${MAJOR_VERSION}" + MOD_PATH="github.com/GoCodeAlone/modular/modules/${M}/v${MAJOR_VERSION}" else - MOD_PATH="github.com/CrisisTextLine/modular/modules/${M}" + MOD_PATH="github.com/GoCodeAlone/modular/modules/${M}" fi if gh release view "$TAG" >/dev/null 2>&1; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7064b6fd..4ec4ffe1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -424,9 +424,9 @@ jobs: # Construct correct module path with version suffix for v2+ if [ "$MAJOR_VERSION" -ge 2 ]; then - MODULE_NAME="github.com/CrisisTextLine/modular/v${MAJOR_VERSION}" + MODULE_NAME="github.com/GoCodeAlone/modular/v${MAJOR_VERSION}" else - MODULE_NAME="github.com/CrisisTextLine/modular" + MODULE_NAME="github.com/GoCodeAlone/modular" fi echo "Announcing ${MODULE_NAME}@${VERSION} to Go proxy..." diff --git a/API_CONTRACT_MANAGEMENT.md b/API_CONTRACT_MANAGEMENT.md index c007327a..ba712ee3 100644 --- a/API_CONTRACT_MANAGEMENT.md +++ b/API_CONTRACT_MANAGEMENT.md @@ -78,7 +78,7 @@ modcli contract extract . modcli contract extract ./modules/auth # Extract from remote package -modcli contract extract github.com/CrisisTextLine/modular +modcli contract extract github.com/GoCodeAlone/modular # Save to file with verbose output modcli contract extract . -o contract.json -v diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 9b43149f..f022496b 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -968,8 +968,8 @@ import ( "fmt" "os" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/database" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/database" ) func main() { @@ -1423,7 +1423,7 @@ The Modular framework provides several debugging utilities to help diagnose comm Use `DebugModuleInterfaces` to check which interfaces a specific module implements: ```go -import "github.com/CrisisTextLine/modular" +import "github.com/GoCodeAlone/modular" // Debug a specific module modular.DebugModuleInterfaces(app, "your-module-name") @@ -1534,7 +1534,7 @@ modular.CompareModuleInstances(originalModule, currentModule, "module-name") For detailed analysis of why a module doesn't implement Startable: ```go -import "github.com/CrisisTextLine/modular" +import "github.com/GoCodeAlone/modular" // Check specific module modular.CheckModuleStartableImplementation(yourModule) diff --git a/GO_MODULE_VERSIONING.md b/GO_MODULE_VERSIONING.md index a96c250a..b356994a 100644 --- a/GO_MODULE_VERSIONING.md +++ b/GO_MODULE_VERSIONING.md @@ -14,7 +14,7 @@ Go modules follow semantic versioning (semver) with a special requirement for ma ```go // go.mod -module github.com/CrisisTextLine/modular +module github.com/GoCodeAlone/modular ``` Tags: @@ -27,7 +27,7 @@ Tags: ```go // go.mod for v2 -module github.com/CrisisTextLine/modular/v2 +module github.com/GoCodeAlone/modular/v2 ``` Tags: @@ -79,7 +79,7 @@ This two-step approach provides several benefits: **Initial State (v1.x.x):** ```go // modules/reverseproxy/go.mod -module github.com/CrisisTextLine/modular/modules/reverseproxy +module github.com/GoCodeAlone/modular/modules/reverseproxy ``` **Step 1: Trigger v2.0.0 Release** @@ -99,7 +99,7 @@ When you trigger a release for v2.0.0, the workflow: After merging the PR: ```go // modules/reverseproxy/go.mod (now updated) -module github.com/CrisisTextLine/modular/modules/reverseproxy/v2 +module github.com/GoCodeAlone/modular/modules/reverseproxy/v2 ``` Re-run the same release workflow, and it will: @@ -107,7 +107,7 @@ Re-run the same release workflow, and it will: 2. Skip the PR creation 3. Create tag `modules/reverseproxy/v2.0.0` 4. Generate release with changelog -5. Announce `github.com/CrisisTextLine/modular/modules/reverseproxy/v2@v2.0.0` to Go proxy +5. Announce `github.com/GoCodeAlone/modular/modules/reverseproxy/v2@v2.0.0` to Go proxy ## Manual Version Updates @@ -117,11 +117,11 @@ If you need to manually prepare for a v2+ release: ```bash # 1. Update go.mod -sed -i 's|^module github.com/CrisisTextLine/modular$|module github.com/CrisisTextLine/modular/v2|' go.mod +sed -i 's|^module github.com/GoCodeAlone/modular$|module github.com/GoCodeAlone/modular/v2|' go.mod # 2. Update import paths in all .go files (if any self-imports) find . -name "*.go" -type f -not -path "*/modules/*" -not -path "*/examples/*" \ - -exec sed -i 's|github.com/CrisisTextLine/modular"|github.com/CrisisTextLine/modular/v2"|g' {} + + -exec sed -i 's|github.com/GoCodeAlone/modular"|github.com/GoCodeAlone/modular/v2"|g' {} + # 3. Run go mod tidy go mod tidy @@ -137,12 +137,12 @@ MODULE_NAME="reverseproxy" # Change this to your module name MAJOR_VERSION="2" # Change to your target major version # 1. Update go.mod -sed -i "s|^module github.com/CrisisTextLine/modular/modules/${MODULE_NAME}$|module github.com/CrisisTextLine/modular/modules/${MODULE_NAME}/v${MAJOR_VERSION}|" \ +sed -i "s|^module github.com/GoCodeAlone/modular/modules/${MODULE_NAME}$|module github.com/GoCodeAlone/modular/modules/${MODULE_NAME}/v${MAJOR_VERSION}|" \ modules/${MODULE_NAME}/go.mod # 2. Update import paths (if module has self-imports - rare) find modules/${MODULE_NAME} -name "*.go" -type f \ - -exec sed -i "s|github.com/CrisisTextLine/modular/modules/${MODULE_NAME}\"|github.com/CrisisTextLine/modular/modules/${MODULE_NAME}/v${MAJOR_VERSION}\"|g" {} + + -exec sed -i "s|github.com/GoCodeAlone/modular/modules/${MODULE_NAME}\"|github.com/GoCodeAlone/modular/modules/${MODULE_NAME}/v${MAJOR_VERSION}\"|g" {} + # 3. Run go mod tidy cd modules/${MODULE_NAME} @@ -158,20 +158,20 @@ When using v2+ versions in your code: ```go // For v1.x.x -import "github.com/CrisisTextLine/modular/modules/reverseproxy" +import "github.com/GoCodeAlone/modular/modules/reverseproxy" // For v2.x.x -import "github.com/CrisisTextLine/modular/modules/reverseproxy/v2" +import "github.com/GoCodeAlone/modular/modules/reverseproxy/v2" // For v3.x.x -import "github.com/CrisisTextLine/modular/modules/reverseproxy/v3" +import "github.com/GoCodeAlone/modular/modules/reverseproxy/v3" ``` In `go.mod`: ```go require ( - github.com/CrisisTextLine/modular/v2 v2.0.0 - github.com/CrisisTextLine/modular/modules/reverseproxy/v2 v2.0.0 + github.com/GoCodeAlone/modular/v2 v2.0.0 + github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.0.0 ) ``` diff --git a/PRIORITY_SYSTEM_GUIDE.md b/PRIORITY_SYSTEM_GUIDE.md index b2314c1d..507457e5 100644 --- a/PRIORITY_SYSTEM_GUIDE.md +++ b/PRIORITY_SYSTEM_GUIDE.md @@ -9,7 +9,7 @@ The Modular framework now supports explicit priority control for configuration f ### Basic Usage ```go -import "github.com/CrisisTextLine/modular/feeders" +import "github.com/GoCodeAlone/modular/feeders" // Add feeders with priority control config.AddFeeder(feeders.NewYamlFeeder("config.yaml").WithPriority(50)) diff --git a/README.md b/README.md index 65f1b918..d66728d1 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # modular Modular Go -[![GitHub License](https://img.shields.io/github/license/CrisisTextLine/modular)](https://github.com/CrisisTextLine/modular/blob/main/LICENSE) -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular) -[![CodeQL](https://github.com/CrisisTextLine/modular/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/github-code-scanning/codeql) -[![Dependabot Updates](https://github.com/CrisisTextLine/modular/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/dependabot/dependabot-updates) -[![CI](https://github.com/CrisisTextLine/modular/actions/workflows/ci.yml/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/ci.yml) -[![Modules CI](https://github.com/CrisisTextLine/modular/actions/workflows/modules-ci.yml/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/modules-ci.yml) -[![Examples CI](https://github.com/CrisisTextLine/modular/actions/workflows/examples-ci.yml/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/examples-ci.yml) -[![Go Report Card](https://goreportcard.com/badge/github.com/CrisisTextLine/modular)](https://goreportcard.com/report/github.com/CrisisTextLine/modular) -[![codecov](https://codecov.io/gh/CrisisTextLine/modular/graph/badge.svg?token=2HCVC9RTN8)](https://codecov.io/gh/CrisisTextLine/modular) +[![GitHub License](https://img.shields.io/github/license/GoCodeAlone/modular)](https://github.com/GoCodeAlone/modular/blob/main/LICENSE) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular) +[![CodeQL](https://github.com/GoCodeAlone/modular/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/github-code-scanning/codeql) +[![Dependabot Updates](https://github.com/GoCodeAlone/modular/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/dependabot/dependabot-updates) +[![CI](https://github.com/GoCodeAlone/modular/actions/workflows/ci.yml/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/ci.yml) +[![Modules CI](https://github.com/GoCodeAlone/modular/actions/workflows/modules-ci.yml/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/modules-ci.yml) +[![Examples CI](https://github.com/GoCodeAlone/modular/actions/workflows/examples-ci.yml/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/examples-ci.yml) +[![Go Report Card](https://goreportcard.com/badge/github.com/GoCodeAlone/modular)](https://goreportcard.com/report/github.com/GoCodeAlone/modular) +[![codecov](https://codecov.io/gh/GoCodeAlone/modular/graph/badge.svg?token=2HCVC9RTN8)](https://codecov.io/gh/GoCodeAlone/modular) ## Testing @@ -144,7 +144,7 @@ Visit the [examples directory](./examples/) for detailed documentation, configur ## Installation ```go -go get github.com/CrisisTextLine/modular +go get github.com/GoCodeAlone/modular ``` ## Usage @@ -155,7 +155,7 @@ go get github.com/CrisisTextLine/modular package main import ( - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "log/slog" "os" ) @@ -702,20 +702,20 @@ You can install the CLI tool using one of the following methods: #### Using go install (recommended) ```bash -go install github.com/CrisisTextLine/modular/cmd/modcli@latest +go install github.com/GoCodeAlone/modular/cmd/modcli@latest ``` This will download, build, and install the latest version of the CLI tool directly to your GOPATH's bin directory, which should be in your PATH. #### Download pre-built binaries -Download the latest release from the [GitHub Releases page](https://github.com/CrisisTextLine/modular/releases) and add it to your PATH. +Download the latest release from the [GitHub Releases page](https://github.com/GoCodeAlone/modular/releases) and add it to your PATH. #### Build from source ```bash # Clone the repository -git clone https://github.com/CrisisTextLine/modular.git +git clone https://github.com/GoCodeAlone/modular.git cd modular/cmd/modcli # Build the CLI tool diff --git a/application.go b/application.go index 854f2285..1bf64c94 100644 --- a/application.go +++ b/application.go @@ -1,6 +1,7 @@ package modular import ( + "maps" "context" "errors" "fmt" @@ -9,6 +10,8 @@ import ( "reflect" "slices" "strings" + "sync" + "sync/atomic" "syscall" "time" ) @@ -336,6 +339,13 @@ type StdApplication struct { configFeeders []Feeder // Optional per-application feeders (override global ConfigFeeders if non-nil) startTime time.Time // Tracks when the application was started configLoadedHooks []func(Application) error // Hooks to run after config loading but before module initialization + dependencyHints []DependencyEdge // Config-driven dependency edges injected via WithModuleDependency + drainTimeout time.Duration // Timeout for pre-stop drain phase + phase atomic.Int32 // Current lifecycle phase (AppPhase) + parallelInit bool // Enable parallel module initialization at same topo depth + initMu sync.Mutex // Guards SetCurrentModule/ClearCurrentModule in parallel init + dynamicReload bool // Enable dynamic reload orchestrator + reloadOrchestrator *ReloadOrchestrator // Coordinates config reload across Reloadable modules } // NewStdApplication creates a new application instance with the provided configuration and logger. @@ -482,7 +492,7 @@ func (app *StdApplication) GetService(name string, target any) error { } targetValue := reflect.ValueOf(target) - if targetValue.Kind() != reflect.Ptr || targetValue.IsNil() { + if targetValue.Kind() != reflect.Pointer || targetValue.IsNil() { return ErrTargetNotPointer } @@ -517,7 +527,7 @@ func (app *StdApplication) GetService(name string, target any) error { if serviceType.AssignableTo(targetType) { targetValue.Elem().Set(reflect.ValueOf(service)) return nil - } else if serviceType.Kind() == reflect.Ptr && serviceType.Elem().AssignableTo(targetType) { + } else if serviceType.Kind() == reflect.Pointer && serviceType.Elem().AssignableTo(targetType) { targetValue.Elem().Set(reflect.ValueOf(service).Elem()) return nil } @@ -526,6 +536,115 @@ func (app *StdApplication) GetService(name string, target any) error { ErrServiceIncompatible, name, serviceType, targetType) } +// Phase returns the current lifecycle phase of the application. +func (app *StdApplication) Phase() AppPhase { + return AppPhase(app.phase.Load()) +} + +func (app *StdApplication) setPhase(p AppPhase) { + app.phase.Store(int32(p)) +} + +// computeDepthLevels groups module names from a topological order into levels +// where modules at the same level have no dependencies on each other and can +// be initialized concurrently. +func (app *StdApplication) computeDepthLevels(order []string) [][]string { + // Build dependency set per module + deps := make(map[string]map[string]bool) + for _, name := range order { + deps[name] = make(map[string]bool) + module := app.moduleRegistry[name] + if da, ok := module.(DependencyAware); ok { + for _, d := range da.Dependencies() { + deps[name][d] = true + } + } + } + // Add config-driven hints + for _, hint := range app.dependencyHints { + if deps[hint.From] != nil { + deps[hint.From][hint.To] = true + } + } + + placed := make(map[string]bool) + var levels [][]string + + for len(placed) < len(order) { + var level []string + for _, name := range order { + if placed[name] { + continue + } + // Check if all deps are placed + ready := true + for dep := range deps[name] { + if !placed[dep] { + ready = false + break + } + } + if ready { + level = append(level, name) + } + } + for _, name := range level { + placed[name] = true + } + levels = append(levels, level) + } + return levels +} + +// initModule initializes a single module: injects services, calls Init, registers provided services. +// Thread-safe: the EnhancedServiceRegistry has its own mutex protecting concurrent access. +func (app *StdApplication) initModule(appToPass Application, moduleName string) error { + app.initMu.Lock() + module := app.moduleRegistry[moduleName] + + if _, ok := module.(ServiceAware); ok { + var err error + app.moduleRegistry[moduleName], err = app.injectServices(module) + if err != nil { + app.initMu.Unlock() + return fmt.Errorf("failed to inject services for module '%s': %w", moduleName, err) + } + module = app.moduleRegistry[moduleName] + } + app.initMu.Unlock() + + // Set current module context for service registration tracking + // EnhancedServiceRegistry has its own mutex, safe for concurrent access + if app.enhancedSvcRegistry != nil { + app.enhancedSvcRegistry.SetCurrentModule(module) + } + + if err := module.Init(appToPass); err != nil { + if app.enhancedSvcRegistry != nil { + app.enhancedSvcRegistry.ClearCurrentModule() + } + return fmt.Errorf("module '%s' failed to initialize: %w", moduleName, err) + } + + if _, ok := module.(ServiceAware); ok { + for _, svc := range module.(ServiceAware).ProvidesServices() { + if err := app.RegisterService(svc.Name, svc.Instance); err != nil { + if app.enhancedSvcRegistry != nil { + app.enhancedSvcRegistry.ClearCurrentModule() + } + return fmt.Errorf("module '%s' failed to register service '%s': %w", moduleName, svc.Name, err) + } + } + } + + if app.enhancedSvcRegistry != nil { + app.enhancedSvcRegistry.ClearCurrentModule() + } + + app.logger.Info(fmt.Sprintf("Initialized module %s of type %T", moduleName, app.moduleRegistry[moduleName])) + return nil +} + // Init initializes the application with the provided modules func (app *StdApplication) Init() error { return app.InitWithApp(app) @@ -543,6 +662,8 @@ func (app *StdApplication) InitWithApp(appToPass Application) error { return nil } + app.setPhase(PhaseInitializing) + errs := make([]error, 0) for name, module := range app.moduleRegistry { configurableModule, ok := module.(Configurable) @@ -589,46 +710,38 @@ func (app *StdApplication) InitWithApp(appToPass Application) error { } // Initialize modules in order - for _, moduleName := range moduleOrder { - module := app.moduleRegistry[moduleName] - - if _, ok := module.(ServiceAware); ok { - // Inject required services - app.moduleRegistry[moduleName], err = app.injectServices(module) - if err != nil { - errs = append(errs, fmt.Errorf("failed to inject services for module '%s': %w", moduleName, err)) - continue - } - module = app.moduleRegistry[moduleName] // Update reference after injection - } - - // Set current module context for service registration tracking - if app.enhancedSvcRegistry != nil { - app.enhancedSvcRegistry.SetCurrentModule(module) - } - - if err = module.Init(appToPass); err != nil { - errs = append(errs, fmt.Errorf("module '%s' failed to initialize: %w", moduleName, err)) - continue - } - - if _, ok := module.(ServiceAware); ok { - // Register services provided by modules - for _, svc := range module.(ServiceAware).ProvidesServices() { - if err = app.RegisterService(svc.Name, svc.Instance); err != nil { - // Collect registration errors (e.g., duplicates) for reporting - errs = append(errs, fmt.Errorf("module '%s' failed to register service '%s': %w", moduleName, svc.Name, err)) - continue + if app.parallelInit { + // Parallel init: group modules by topological depth and init each level concurrently + levels := app.computeDepthLevels(moduleOrder) + for _, level := range levels { + if len(level) == 1 { + if initErr := app.initModule(appToPass, level[0]); initErr != nil { + errs = append(errs, initErr) + } + } else { + var wg sync.WaitGroup + var mu sync.Mutex + for _, moduleName := range level { + wg.Add(1) + go func(name string) { + defer wg.Done() + if initErr := app.initModule(appToPass, name); initErr != nil { + mu.Lock() + errs = append(errs, initErr) + mu.Unlock() + } + }(moduleName) } + wg.Wait() } } - - // Clear current module context - if app.enhancedSvcRegistry != nil { - app.enhancedSvcRegistry.ClearCurrentModule() + } else { + // Sequential init (original behavior) + for _, moduleName := range moduleOrder { + if initErr := app.initModule(appToPass, moduleName); initErr != nil { + errs = append(errs, initErr) + } } - - app.logger.Info(fmt.Sprintf("Initialized module %s of type %T", moduleName, app.moduleRegistry[moduleName])) } // Initialize tenant configuration after modules have registered their configurations @@ -636,9 +749,24 @@ func (app *StdApplication) InitWithApp(appToPass Application) error { errs = append(errs, fmt.Errorf("failed to initialize tenant configurations: %w", err)) } + // Wire up the ReloadOrchestrator if dynamic reload is enabled + if app.dynamicReload { + var subject Subject + if obsApp, ok := appToPass.(*ObservableApplication); ok { + subject = obsApp + } + app.reloadOrchestrator = NewReloadOrchestrator(app.logger, subject) + for name, module := range app.moduleRegistry { + if reloadable, ok := module.(Reloadable); ok { + app.reloadOrchestrator.RegisterReloadable(name, reloadable) + } + } + } + // Mark as initialized only after completing Init flow if len(errs) == 0 { app.initialized = true + app.setPhase(PhaseInitialized) } return errors.Join(errs...) @@ -676,6 +804,8 @@ func (app *StdApplication) initTenantConfigurations() error { // Start starts the application func (app *StdApplication) Start() error { + app.setPhase(PhaseStarting) + // Record the start time app.startTime = time.Now() @@ -703,11 +833,22 @@ func (app *StdApplication) Start() error { } } + if app.reloadOrchestrator != nil { + app.reloadOrchestrator.Start(ctx) + } + + app.setPhase(PhaseRunning) return nil } // Stop stops the application func (app *StdApplication) Stop() error { + if app.reloadOrchestrator != nil { + app.reloadOrchestrator.Stop() + } + + app.setPhase(PhaseDraining) + // Get modules in reverse dependency order modules, err := app.resolveDependencies() if err != nil { @@ -717,7 +858,27 @@ func (app *StdApplication) Stop() error { // Reverse the slice slices.Reverse(modules) - // Create timeout context for shutdown + // Phase 1: Drain + drainTimeout := app.drainTimeout + if drainTimeout <= 0 { + drainTimeout = defaultDrainTimeout + } + drainCtx, drainCancel := context.WithTimeout(context.Background(), drainTimeout) + defer drainCancel() + + for _, name := range modules { + module := app.moduleRegistry[name] + if drainable, ok := module.(Drainable); ok { + app.logger.Info("Draining module", "module", name) + if err := drainable.PreStop(drainCtx); err != nil { + app.logger.Error("Error draining module", "module", name, "error", err) + } + } + } + + app.setPhase(PhaseStopping) + + // Phase 2: Stop ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -727,7 +888,6 @@ func (app *StdApplication) Stop() error { module := app.moduleRegistry[name] stoppableModule, ok := module.(Stoppable) if !ok { - app.logger.Debug("Module does not implement Stoppable, skipping", "module", name) continue } app.logger.Info("Stopping module", "module", name) @@ -742,9 +902,19 @@ func (app *StdApplication) Stop() error { app.cancel() } + app.setPhase(PhaseStopped) return lastErr } +// RequestReload enqueues a configuration reload request with the ReloadOrchestrator. +// Returns an error if dynamic reload was not enabled via WithDynamicReload(). +func (app *StdApplication) RequestReload(ctx context.Context, trigger ReloadTrigger, diff ConfigDiff) error { + if app.reloadOrchestrator == nil { + return ErrDynamicReloadNotEnabled + } + return app.reloadOrchestrator.RequestReload(ctx, trigger, diff) +} + // Run starts the application and blocks until termination func (app *StdApplication) Run() error { // Initialize @@ -1103,6 +1273,15 @@ func (app *StdApplication) resolveDependencies() ([]string, error) { } } + // Merge config-driven dependency hints + for _, hint := range app.dependencyHints { + if graph[hint.From] == nil { + graph[hint.From] = nil + } + graph[hint.From] = append(graph[hint.From], hint.To) + dependencyEdges = append(dependencyEdges, hint) + } + // Analyze service dependencies to augment the graph with implicit dependencies serviceEdges := app.addImplicitDependencies(graph) dependencyEdges = append(dependencyEdges, serviceEdges...) @@ -1434,7 +1613,7 @@ func (app *StdApplication) typeImplementsInterface(svcType, interfaceType reflec if svcType.Implements(interfaceType) { return true } - if svcType.Kind() == reflect.Ptr { + if svcType.Kind() == reflect.Pointer { et := svcType.Elem() if et != nil && et.Implements(interfaceType) { return true @@ -1496,11 +1675,9 @@ func (app *StdApplication) addNameBasedDependency( } // Check if dependency already exists - for _, existingDep := range graph[consumerName] { - if existingDep == providerModule { + if slices.Contains(graph[consumerName], providerModule) { return nil // Already exists } - } // Add the dependency if graph[consumerName] == nil { @@ -1549,11 +1726,9 @@ func (app *StdApplication) addInterfaceBasedDependencyWithTypeInfo(match Interfa app.logger.Debug("Adding required self interface dependency to expose unsatisfiable self-requirement", "module", match.Consumer, "interface", match.InterfaceType.Name(), "service", match.ServiceName) } // Check if this dependency already exists - for _, existingDep := range graph[match.Consumer] { - if existingDep == match.Provider { + if slices.Contains(graph[match.Consumer], match.Provider) { return nil } - } // Add the dependency (including self-dependencies for cycle detection) if graph[match.Consumer] == nil { @@ -1644,12 +1819,21 @@ func (app *StdApplication) GetModule(name string) Module { // Returns a copy to prevent external modification of the module registry. func (app *StdApplication) GetAllModules() map[string]Module { result := make(map[string]Module, len(app.moduleRegistry)) - for k, v := range app.moduleRegistry { - result[k] = v - } + maps.Copy(result, app.moduleRegistry) return result } +// CollectAllMetrics gathers metrics from all modules implementing MetricsProvider. +func (app *StdApplication) CollectAllMetrics(ctx context.Context) []ModuleMetrics { + var results []ModuleMetrics + for _, module := range app.moduleRegistry { + if mp, ok := module.(MetricsProvider); ok { + results = append(results, mp.CollectMetrics(ctx)) + } + } + return results +} + // OnConfigLoaded registers a callback to run after config loading but before module initialization. // This allows reconfiguring dependencies based on loaded configuration values. // Multiple hooks can be registered and will be executed in registration order. diff --git a/application_issue_reproduction_test.go b/application_issue_reproduction_test.go index 1e4f90e7..07623a03 100644 --- a/application_issue_reproduction_test.go +++ b/application_issue_reproduction_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" "github.com/stretchr/testify/mock" ) diff --git a/application_lifecycle_bdd_test.go b/application_lifecycle_bdd_test.go index 213ad8ba..80fbed44 100644 --- a/application_lifecycle_bdd_test.go +++ b/application_lifecycle_bdd_test.go @@ -37,7 +37,7 @@ type BDDTestContext struct { startError error stopError error moduleStates map[string]bool - servicesFound map[string]interface{} + servicesFound map[string]any } // Test modules for BDD scenarios @@ -87,7 +87,7 @@ type MockTestService struct{} type ConsumerTestModule struct { SimpleTestModule - receivedService interface{} + receivedService any } func (m *ConsumerTestModule) Init(app Application) error { @@ -121,7 +121,7 @@ func (ctx *BDDTestContext) resetContext() { ctx.startError = nil ctx.stopError = nil ctx.moduleStates = make(map[string]bool) - ctx.servicesFound = make(map[string]interface{}) + ctx.servicesFound = make(map[string]any) } func (ctx *BDDTestContext) iHaveANewModularApplication() error { @@ -386,16 +386,16 @@ func (ctx *BDDTestContext) theErrorShouldIndicateCircularDependency() error { // BDDTestLogger for BDD tests type BDDTestLogger struct{} -func (l *BDDTestLogger) Debug(msg string, fields ...interface{}) {} -func (l *BDDTestLogger) Info(msg string, fields ...interface{}) {} -func (l *BDDTestLogger) Warn(msg string, fields ...interface{}) {} -func (l *BDDTestLogger) Error(msg string, fields ...interface{}) {} +func (l *BDDTestLogger) Debug(msg string, fields ...any) {} +func (l *BDDTestLogger) Info(msg string, fields ...any) {} +func (l *BDDTestLogger) Warn(msg string, fields ...any) {} +func (l *BDDTestLogger) Error(msg string, fields ...any) {} // InitializeScenario initializes the BDD test scenario func InitializeScenario(ctx *godog.ScenarioContext) { testCtx := &BDDTestContext{ moduleStates: make(map[string]bool), - servicesFound: make(map[string]interface{}), + servicesFound: make(map[string]any), } // Reset context before each scenario diff --git a/application_logger_test.go b/application_logger_test.go index 9e2fb81a..3de60068 100644 --- a/application_logger_test.go +++ b/application_logger_test.go @@ -76,7 +76,7 @@ func Test_ApplicationSetLoggerRuntimeUsage(t *testing.T) { // Create a new mock logger to switch to newMockLogger := &MockLogger{} // Set up a simple expectation that might be called later - newMockLogger.On("Debug", "Test message", []interface{}{"key", "value"}).Return().Maybe() + newMockLogger.On("Debug", "Test message", []any{"key", "value"}).Return().Maybe() // Switch to the new logger app.SetLogger(newMockLogger) @@ -120,9 +120,9 @@ func TestSetVerboseConfig(t *testing.T) { // Set up expectations for debug messages if tt.enabled { - mockLogger.On("Debug", "Verbose configuration debugging enabled", []interface{}(nil)).Return() + mockLogger.On("Debug", "Verbose configuration debugging enabled", []any(nil)).Return() } else { - mockLogger.On("Debug", "Verbose configuration debugging disabled", []interface{}(nil)).Return() + mockLogger.On("Debug", "Verbose configuration debugging disabled", []any(nil)).Return() } // Create application with mock logger @@ -165,14 +165,14 @@ func TestIsVerboseConfig(t *testing.T) { } // Test after enabling - mockLogger.On("Debug", "Verbose configuration debugging enabled", []interface{}(nil)).Return() + mockLogger.On("Debug", "Verbose configuration debugging enabled", []any(nil)).Return() app.SetVerboseConfig(true) if app.IsVerboseConfig() != true { t.Error("Expected IsVerboseConfig to return true after enabling") } // Test after disabling - mockLogger.On("Debug", "Verbose configuration debugging disabled", []interface{}(nil)).Return() + mockLogger.On("Debug", "Verbose configuration debugging disabled", []any(nil)).Return() app.SetVerboseConfig(false) if app.IsVerboseConfig() != false { t.Error("Expected IsVerboseConfig to return false after disabling") diff --git a/application_observer.go b/application_observer.go index 0e492269..35f71e38 100644 --- a/application_observer.go +++ b/application_observer.go @@ -93,7 +93,6 @@ func (app *ObservableApplication) NotifyObservers(ctx context.Context, event clo // Otherwise, notify observers in goroutines to avoid blocking. synchronous := IsSynchronousNotification(ctx) for _, registration := range app.observers { - registration := registration // capture for goroutine // Check if observer is interested in this event type if len(registration.eventTypes) > 0 && !registration.eventTypes[event.Type()] { @@ -163,7 +162,7 @@ func (app *ObservableApplication) RegisterModule(module Module) { // Emit synchronously so tests observing immediate module registration are reliable. ctx := WithSynchronousNotification(context.Background()) - evt := NewModuleLifecycleEvent("application", "module", module.Name(), "", "registered", map[string]interface{}{ + evt := NewModuleLifecycleEvent("application", "module", module.Name(), "", "registered", map[string]any{ "moduleType": getTypeName(module), }) app.emitEvent(ctx, evt) @@ -176,7 +175,7 @@ func (app *ObservableApplication) RegisterService(name string, service any) erro return err } - evt := NewCloudEvent(EventTypeServiceRegistered, "application", map[string]interface{}{ + evt := NewCloudEvent(EventTypeServiceRegistered, "application", map[string]any{ "serviceName": name, "serviceType": getTypeName(service), }, nil) @@ -199,7 +198,7 @@ func (app *ObservableApplication) Init() error { // Historically the framework emitted config loaded/validated events during initialization. // Even though structured lifecycle events now exist, tests (and possibly external observers) // still expect these generic configuration events to appear. - cfgLoaded := NewCloudEvent(EventTypeConfigLoaded, "application", map[string]interface{}{"phase": "init"}, nil) + cfgLoaded := NewCloudEvent(EventTypeConfigLoaded, "application", map[string]any{"phase": "init"}, nil) app.emitEvent(ctx, cfgLoaded) // Register observers for any ObservableModule instances BEFORE calling module Init() @@ -219,17 +218,17 @@ func (app *ObservableApplication) Init() error { app.logger.Debug("ObservableApplication initializing modules with observable application instance") err := app.InitWithApp(app) if err != nil { - failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]interface{}{"phase": "init", "error": err.Error()}) + failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]any{"phase": "init", "error": err.Error()}) app.emitEvent(ctx, failureEvt) return err } // Backward compatibility: emit legacy config.validated event after successful initialization. - cfgValidated := NewCloudEvent(EventTypeConfigValidated, "application", map[string]interface{}{"phase": "init_complete"}, nil) + cfgValidated := NewCloudEvent(EventTypeConfigValidated, "application", map[string]any{"phase": "init_complete"}, nil) app.emitEvent(ctx, cfgValidated) // Emit initialization complete - evtInitComplete := NewModuleLifecycleEvent("application", "application", "", "", "initialized", map[string]interface{}{"phase": "init_complete"}) + evtInitComplete := NewModuleLifecycleEvent("application", "application", "", "", "initialized", map[string]any{"phase": "init_complete"}) app.emitEvent(ctx, evtInitComplete) return nil @@ -241,7 +240,7 @@ func (app *ObservableApplication) Start() error { err := app.StdApplication.Start() if err != nil { - failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]interface{}{"phase": "start", "error": err.Error()}) + failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]any{"phase": "start", "error": err.Error()}) app.emitEvent(ctx, failureEvt) return err } @@ -259,7 +258,7 @@ func (app *ObservableApplication) Stop() error { err := app.StdApplication.Stop() if err != nil { - failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]interface{}{"phase": "stop", "error": err.Error()}) + failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]any{"phase": "stop", "error": err.Error()}) app.emitEvent(ctx, failureEvt) return err } @@ -272,7 +271,7 @@ func (app *ObservableApplication) Stop() error { } // getTypeName returns the type name of an interface{} value -func getTypeName(v interface{}) string { +func getTypeName(v any) string { if v == nil { return "nil" } diff --git a/application_observer_test.go b/application_observer_test.go index 808062bb..541a4f06 100644 --- a/application_observer_test.go +++ b/application_observer_test.go @@ -337,28 +337,28 @@ type TestObserverLogger struct { type LogEntry struct { Level string Message string - Args []interface{} + Args []any } -func (l *TestObserverLogger) Info(msg string, args ...interface{}) { +func (l *TestObserverLogger) Info(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, LogEntry{Level: "INFO", Message: msg, Args: args}) } -func (l *TestObserverLogger) Error(msg string, args ...interface{}) { +func (l *TestObserverLogger) Error(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, LogEntry{Level: "ERROR", Message: msg, Args: args}) } -func (l *TestObserverLogger) Debug(msg string, args ...interface{}) { +func (l *TestObserverLogger) Debug(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, LogEntry{Level: "DEBUG", Message: msg, Args: args}) } -func (l *TestObserverLogger) Warn(msg string, args ...interface{}) { +func (l *TestObserverLogger) Warn(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, LogEntry{Level: "WARN", Message: msg, Args: args}) diff --git a/application_service_registry_test.go b/application_service_registry_test.go index b8b5ab7b..8e3070de 100644 --- a/application_service_registry_test.go +++ b/application_service_registry_test.go @@ -50,7 +50,7 @@ func Test_GetService(t *testing.T) { tests := []struct { name string serviceName string - target interface{} + target any wantErr bool errCheck func(error) bool }{ diff --git a/base_config_support.go b/base_config_support.go index 4a56c6e5..d1482b77 100644 --- a/base_config_support.go +++ b/base_config_support.go @@ -3,7 +3,7 @@ package modular import ( "os" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // BaseConfigOptions holds configuration for base config support diff --git a/benchmark_test.go b/benchmark_test.go new file mode 100644 index 00000000..802a207c --- /dev/null +++ b/benchmark_test.go @@ -0,0 +1,131 @@ +package modular + +import ( + "context" + "fmt" + "testing" + "time" +) + +// --- Benchmark helpers --- + +// benchModule is a minimal Module for bootstrap benchmarks. +type benchModule struct{ name string } + +func (m *benchModule) Name() string { return m.name } +func (m *benchModule) Init(_ Application) error { return nil } + +// benchReloadable is a fast Reloadable for reload benchmarks. +type benchReloadable struct{ name string } + +func (m *benchReloadable) Name() string { return m.name } +func (m *benchReloadable) Init(_ Application) error { return nil } +func (m *benchReloadable) Reload(_ context.Context, _ []ConfigChange) error { + return nil +} +func (m *benchReloadable) CanReload() bool { return true } +func (m *benchReloadable) ReloadTimeout() time.Duration { return 5 * time.Second } + +// benchLogger is a no-op logger for benchmarks. +type benchLogger struct{} + +func (l *benchLogger) Info(_ string, _ ...any) {} +func (l *benchLogger) Error(_ string, _ ...any) {} +func (l *benchLogger) Warn(_ string, _ ...any) {} +func (l *benchLogger) Debug(_ string, _ ...any) {} + +// BenchmarkBootstrap measures Init time with 10 modules. Target: <150ms. +func BenchmarkBootstrap(b *testing.B) { + modules := make([]Module, 10) + for i := range modules { + modules[i] = &benchModule{name: fmt.Sprintf("bench-mod-%d", i)} + } + + b.ResetTimer() + for b.Loop() { + app, err := NewApplication( + WithLogger(&benchLogger{}), + WithConfigProvider(NewStdConfigProvider(&struct{}{})), + WithModules(modules...), + ) + if err != nil { + b.Fatalf("NewApplication failed: %v", err) + } + + if err := app.Init(); err != nil { + b.Fatalf("Init failed: %v", err) + } + } +} + +// BenchmarkServiceLookup measures service registry lookup. Target: <2us. +func BenchmarkServiceLookup(b *testing.B) { + registry := NewEnhancedServiceRegistry() + _, _ = registry.RegisterService("bench-service", &struct{ Value int }{42}) + svcReg := registry.AsServiceRegistry() + + b.ResetTimer() + for b.Loop() { + _ = svcReg["bench-service"] + } +} + +// BenchmarkReload measures a single reload cycle with 5 modules. Target: <80ms. +func BenchmarkReload(b *testing.B) { + log := &benchLogger{} + orchestrator := NewReloadOrchestrator(log, nil) + + for i := range 5 { + mod := &benchReloadable{name: fmt.Sprintf("reload-mod-%d", i)} + orchestrator.RegisterReloadable(mod.name, mod) + } + + diff := ConfigDiff{ + Changed: map[string]FieldChange{ + "key1": {OldValue: "a", NewValue: "b", FieldPath: "key1", ChangeType: ChangeModified}, + }, + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + } + + ctx := context.Background() + + b.ResetTimer() + for b.Loop() { + req := ReloadRequest{ + Trigger: ReloadManual, + Diff: diff, + Ctx: ctx, + } + // Call processReload directly to measure the actual reload cycle + // without channel/goroutine overhead. + if err := orchestrator.processReload(ctx, req); err != nil { + b.Fatalf("processReload failed: %v", err) + } + } +} + +// BenchmarkHealthAggregation measures health check aggregation with 10 providers. +// Target: <5ms. +func BenchmarkHealthAggregation(b *testing.B) { + svc := NewAggregateHealthService(WithCacheTTL(0)) + + for i := range 10 { + name := fmt.Sprintf("provider-%d", i) + provider := NewSimpleHealthProvider(name, "main", func(_ context.Context) (HealthStatus, string, error) { + return StatusHealthy, "ok", nil + }) + svc.AddProvider(name, provider) + } + + // Force refresh on every call by using ForceHealthRefreshKey. + ctx := context.WithValue(context.Background(), ForceHealthRefreshKey, true) + + b.ResetTimer() + for b.Loop() { + _, err := svc.Check(ctx) + if err != nil { + b.Fatalf("Check failed: %v", err) + } + } +} diff --git a/builder.go b/builder.go index b03b1e88..3d9faed7 100644 --- a/builder.go +++ b/builder.go @@ -2,6 +2,8 @@ package modular import ( "context" + "fmt" + "time" cloudevents "github.com/cloudevents/sdk-go/v2" ) @@ -21,6 +23,13 @@ type ApplicationBuilder struct { enableObserver bool enableTenant bool configLoadedHooks []func(Application) error // Hooks to run after config loading + tenantGuard *StandardTenantGuard + tenantGuardConfig *TenantGuardConfig + dependencyHints []DependencyEdge + drainTimeout time.Duration + parallelInit bool + dynamicReload bool + plugins []Plugin } // ObserverFunc is a functional observer that can be registered with the application @@ -97,6 +106,71 @@ func (b *ApplicationBuilder) Build() (Application, error) { app = NewObservableDecorator(app, b.observers...) } + // Create and register tenant guard if configured. + // Use RegisterService so that the EnhancedServiceRegistry (if enabled) tracks + // the entry and subsequent RegisterService calls don't overwrite it. + if b.tenantGuardConfig != nil { + b.tenantGuard = NewStandardTenantGuard(*b.tenantGuardConfig) + if err := app.RegisterService("tenant.guard", b.tenantGuard); err != nil { + return nil, fmt.Errorf("failed to register tenant guard service: %w", err) + } + } + + // Propagate config-driven dependency hints + if len(b.dependencyHints) > 0 { + if stdApp, ok := app.(*StdApplication); ok { + stdApp.dependencyHints = b.dependencyHints + } else if obsApp, ok := app.(*ObservableApplication); ok { + obsApp.dependencyHints = b.dependencyHints + } + } + + // Propagate drain timeout + if b.drainTimeout > 0 { + if stdApp, ok := app.(*StdApplication); ok { + stdApp.drainTimeout = b.drainTimeout + } else if obsApp, ok := app.(*ObservableApplication); ok { + obsApp.drainTimeout = b.drainTimeout + } + } + + // Propagate dynamic reload + if b.dynamicReload { + if stdApp, ok := app.(*StdApplication); ok { + stdApp.dynamicReload = true + } else if obsApp, ok := app.(*ObservableApplication); ok { + obsApp.dynamicReload = true + } + } + + // Propagate parallel init + if b.parallelInit { + if stdApp, ok := app.(*StdApplication); ok { + stdApp.parallelInit = true + } else if obsApp, ok := app.(*ObservableApplication); ok { + obsApp.parallelInit = true + } + } + + // Process plugins + for _, plugin := range b.plugins { + for _, mod := range plugin.Modules() { + app.RegisterModule(mod) + } + if withSvc, ok := plugin.(PluginWithServices); ok { + for _, svcDef := range withSvc.Services() { + if err := app.RegisterService(svcDef.Name, svcDef.Service); err != nil { + return nil, fmt.Errorf("plugin %q service %q: %w", plugin.Name(), svcDef.Name, err) + } + } + } + if withHooks, ok := plugin.(PluginWithHooks); ok { + for _, hook := range withHooks.InitHooks() { + app.OnConfigLoaded(hook) + } + } + } + // Register modules for _, module := range b.modules { app.RegisterModule(module) @@ -142,6 +216,53 @@ func WithModules(modules ...Module) Option { } } +// WithModuleDependency declares that module `from` depends on module `to`, +// injecting an edge into the dependency graph before resolution. +func WithModuleDependency(from, to string) Option { + return func(b *ApplicationBuilder) error { + b.dependencyHints = append(b.dependencyHints, DependencyEdge{ + From: from, + To: to, + Type: EdgeTypeModule, + }) + return nil + } +} + +// WithDrainTimeout sets the timeout for the pre-stop drain phase during shutdown. +func WithDrainTimeout(d time.Duration) Option { + return func(b *ApplicationBuilder) error { + b.drainTimeout = d + return nil + } +} + +// WithParallelInit enables concurrent module initialization at the same topological depth. +func WithParallelInit() Option { + return func(b *ApplicationBuilder) error { + b.parallelInit = true + return nil + } +} + +// WithDynamicReload enables the ReloadOrchestrator, which coordinates +// configuration reloading across all registered Reloadable modules. +func WithDynamicReload() Option { + return func(b *ApplicationBuilder) error { + b.dynamicReload = true + return nil + } +} + +// WithPlugins adds plugins to the application. Each plugin's modules, services, +// and init hooks are registered during Build(). +func WithPlugins(plugins ...Plugin) Option { + return func(b *ApplicationBuilder) error { + b.plugins = append(b.plugins, plugins...) + return nil + } +} + // WithConfigDecorators adds configuration decorators func WithConfigDecorators(decorators ...ConfigDecorator) Option { return func(b *ApplicationBuilder) error { @@ -194,6 +315,26 @@ func WithOnConfigLoaded(hooks ...func(Application) error) Option { } } +// WithTenantGuardMode enables the tenant guard with the specified mode using default config. +func WithTenantGuardMode(mode TenantGuardMode) Option { + return func(b *ApplicationBuilder) error { + if b.tenantGuardConfig == nil { + cfg := DefaultTenantGuardConfig() + b.tenantGuardConfig = &cfg + } + b.tenantGuardConfig.Mode = mode + return nil + } +} + +// WithTenantGuardConfig enables the tenant guard with a full configuration. +func WithTenantGuardConfig(config TenantGuardConfig) Option { + return func(b *ApplicationBuilder) error { + b.tenantGuardConfig = &config + return nil + } +} + // Convenience functions for creating common decorators // InstanceAwareConfig creates an instance-aware configuration decorator diff --git a/builder_dependency_test.go b/builder_dependency_test.go new file mode 100644 index 00000000..29086e05 --- /dev/null +++ b/builder_dependency_test.go @@ -0,0 +1,62 @@ +package modular + +import ( + "testing" +) + +type testDepModule struct { + name string + initSeq *[]string +} + +func (m *testDepModule) Name() string { return m.name } +func (m *testDepModule) Init(app Application) error { + *m.initSeq = append(*m.initSeq, m.name) + return nil +} + +func TestWithModuleDependency_OrdersModulesCorrectly(t *testing.T) { + seq := make([]string, 0) + modA := &testDepModule{name: "alpha", initSeq: &seq} + modB := &testDepModule{name: "beta", initSeq: &seq} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + WithModuleDependency("alpha", "beta"), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + if len(seq) != 2 || seq[0] != "beta" || seq[1] != "alpha" { + t.Errorf("expected init order [beta, alpha], got %v", seq) + } +} + +func TestWithModuleDependency_DetectsCycle(t *testing.T) { + modA := &testDepModule{name: "alpha", initSeq: new([]string)} + modB := &testDepModule{name: "beta", initSeq: new([]string)} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + WithModuleDependency("alpha", "beta"), + WithModuleDependency("beta", "alpha"), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + err = app.Init() + if err == nil { + t.Fatal("expected circular dependency error") + } + if !IsErrCircularDependency(err) { + t.Errorf("expected ErrCircularDependency, got: %v", err) + } +} diff --git a/cmd/modcli/README.md b/cmd/modcli/README.md index d99f9bc7..71f7e408 100644 --- a/cmd/modcli/README.md +++ b/cmd/modcli/README.md @@ -1,12 +1,12 @@ # ModCLI -[![CI](https://github.com/CrisisTextLine/modular/actions/workflows/ci.yml/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/ci.yml) -[![Release](https://github.com/CrisisTextLine/modular/actions/workflows/cli-release.yml/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/cli-release.yml) -[![codecov](https://codecov.io/gh/CrisisTextLine/modular/branch/main/graph/badge.svg?flag=cli)](https://codecov.io/gh/CrisisTextLine/modular) -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/cmd/modcli.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/cmd/modcli) -[![Go Report Card](https://goreportcard.com/badge/github.com/CrisisTextLine/modular)](https://goreportcard.com/report/github.com/CrisisTextLine/modular) +[![CI](https://github.com/GoCodeAlone/modular/actions/workflows/ci.yml/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/ci.yml) +[![Release](https://github.com/GoCodeAlone/modular/actions/workflows/cli-release.yml/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/cli-release.yml) +[![codecov](https://codecov.io/gh/GoCodeAlone/modular/branch/main/graph/badge.svg?flag=cli)](https://codecov.io/gh/GoCodeAlone/modular) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/cmd/modcli.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/cmd/modcli) +[![Go Report Card](https://goreportcard.com/badge/github.com/GoCodeAlone/modular)](https://goreportcard.com/report/github.com/GoCodeAlone/modular) -ModCLI is a command-line interface tool for the [Modular](https://github.com/CrisisTextLine/modular) framework that helps you scaffold and generate code for modular applications. +ModCLI is a command-line interface tool for the [Modular](https://github.com/GoCodeAlone/modular) framework that helps you scaffold and generate code for modular applications. ## Installation @@ -15,7 +15,7 @@ ModCLI is a command-line interface tool for the [Modular](https://github.com/Cri Install the latest version directly using Go: ```bash -go install github.com/CrisisTextLine/modular/cmd/modcli@latest +go install github.com/GoCodeAlone/modular/cmd/modcli@latest ``` After installation, the `modcli` command will be available in your PATH. @@ -23,14 +23,14 @@ After installation, the `modcli` command will be available in your PATH. ### From Source ```bash -git clone https://github.com/CrisisTextLine/modular.git +git clone https://github.com/GoCodeAlone/modular.git cd modular/cmd/modcli go install ``` ### From Releases -Download the latest release for your platform from the [releases page](https://github.com/CrisisTextLine/modular/releases). +Download the latest release for your platform from the [releases page](https://github.com/GoCodeAlone/modular/releases). ## Commands diff --git a/cmd/modcli/cmd/contract.go b/cmd/modcli/cmd/contract.go index a055c704..c755089f 100644 --- a/cmd/modcli/cmd/contract.go +++ b/cmd/modcli/cmd/contract.go @@ -8,8 +8,8 @@ import ( "path/filepath" "strings" - "github.com/CrisisTextLine/modular/cmd/modcli/internal/contract" - "github.com/CrisisTextLine/modular/cmd/modcli/internal/git" + "github.com/GoCodeAlone/modular/cmd/modcli/internal/contract" + "github.com/GoCodeAlone/modular/cmd/modcli/internal/git" "github.com/spf13/cobra" ) diff --git a/cmd/modcli/cmd/contract_test.go b/cmd/modcli/cmd/contract_test.go index 52c25223..11e4ca90 100644 --- a/cmd/modcli/cmd/contract_test.go +++ b/cmd/modcli/cmd/contract_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "testing" - "github.com/CrisisTextLine/modular/cmd/modcli/internal/contract" + "github.com/GoCodeAlone/modular/cmd/modcli/internal/contract" "github.com/spf13/cobra" ) diff --git a/cmd/modcli/cmd/debug_test.go b/cmd/modcli/cmd/debug_test.go index c685b3d5..e084ae4b 100644 --- a/cmd/modcli/cmd/debug_test.go +++ b/cmd/modcli/cmd/debug_test.go @@ -25,7 +25,7 @@ func createTestProject(t testing.TB) string { moduleContent := `package testmodule import ( - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "reflect" ) diff --git a/cmd/modcli/cmd/generate_config_test.go b/cmd/modcli/cmd/generate_config_test.go index 92cb69e9..726caa72 100644 --- a/cmd/modcli/cmd/generate_config_test.go +++ b/cmd/modcli/cmd/generate_config_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular/cmd/modcli/cmd" + "github.com/GoCodeAlone/modular/cmd/modcli/cmd" "github.com/pelletier/go-toml/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/cmd/modcli/cmd/generate_module.go b/cmd/modcli/cmd/generate_module.go index c7490f74..18b88caa 100644 --- a/cmd/modcli/cmd/generate_module.go +++ b/cmd/modcli/cmd/generate_module.go @@ -58,7 +58,7 @@ import ( "reflect" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // MockApplication implements the modular.Application interface for testing @@ -653,7 +653,7 @@ func generateModuleFile(outputDir string, options *ModuleOptions) error { import ( {{if or .HasStartupLogic .HasShutdownLogic}}"context"{{end}} {{/* Conditionally import context */}} - {{if or .HasConfig .IsTenantAware .ProvidesServices .RequiresServices}}"github.com/CrisisTextLine/modular"{{end}} {{/* Conditionally import modular */}} + {{if or .HasConfig .IsTenantAware .ProvidesServices .RequiresServices}}"github.com/GoCodeAlone/modular"{{end}} {{/* Conditionally import modular */}} "log/slog" {{if .HasConfig}}"fmt"{{end}} {{/* Conditionally import fmt */}} {{if or .HasConfig .IsTenantAware}}"encoding/json"{{end}} {{/* For config unmarshaling */}} @@ -1135,7 +1135,7 @@ func generateTestFiles(outputDir string, options *ModuleOptions) error { import ( {{if or .HasStartupLogic .HasShutdownLogic}}"context"{{end}} {{/* Conditionally import context */}} "testing" - {{if or .IsTenantAware .ProvidesServices .RequiresServices}}"github.com/CrisisTextLine/modular"{{end}} {{/* Conditionally import modular */}} + {{if or .IsTenantAware .ProvidesServices .RequiresServices}}"github.com/GoCodeAlone/modular"{{end}} {{/* Conditionally import modular */}} "github.com/stretchr/testify/assert" {{if or .HasConfig .IsTenantAware .ProvidesServices .RequiresServices}}"github.com/stretchr/testify/require"{{end}} {{/* Conditionally import require */}} {{if .IsTenantAware}}"fmt"{{end}} {{/* Import fmt for error formatting in MockTenantService */}} @@ -1318,7 +1318,7 @@ func generateReadmeFile(outputDir string, options *ModuleOptions) error { // Define the template as a raw string to avoid backtick-related syntax issues readmeContent := `# {{.ModuleName}} Module -A module for the [Modular](https://github.com/CrisisTextLine/modular) framework. +A module for the [Modular](https://github.com/GoCodeAlone/modular) framework. ## Overview @@ -1342,7 +1342,7 @@ go get github.com/yourusername/{{.PackageName}} package main import ( - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/yourusername/{{.PackageName}}" "log/slog" "os" @@ -1547,7 +1547,7 @@ func generateGoModFile(outputDir string, options *ModuleOptions) error { // } // Add requirements (adjust versions as needed) - if err := newModFile.AddRequire("github.com/CrisisTextLine/modular", "v1.6.0"); err != nil { + if err := newModFile.AddRequire("github.com/GoCodeAlone/modular", "v1.6.0"); err != nil { return fmt.Errorf("failed to add modular requirement: %w", err) } if options.GenerateTests { @@ -1619,11 +1619,11 @@ func generateGoldenGoMod(options *ModuleOptions, goModPath string) error { go 1.25 require ( - github.com/CrisisTextLine/modular v1.6.0 + github.com/GoCodeAlone/modular v1.6.0 github.com/stretchr/testify v1.11.0 ) - replace github.com/CrisisTextLine/modular => ../../../../../../ + replace github.com/GoCodeAlone/modular => ../../../../../../ `, modulePath) err := os.WriteFile(goModPath, []byte(goModContent), 0600) if err != nil { @@ -1700,7 +1700,7 @@ func findParentGoMod() (string, error) { if _, err := os.Stat(goModPath); err == nil { // Check if it's the root go.mod of the modular project itself, if so, skip it content, errRead := os.ReadFile(goModPath) - if errRead == nil && strings.Contains(string(content), "module github.com/CrisisTextLine/modular\\n") { + if errRead == nil && strings.Contains(string(content), "module github.com/GoCodeAlone/modular\\n") { // This is the main project's go.mod, continue searching upwards slog.Debug("Found main project go.mod, continuing search for parent", "path", goModPath) } else { diff --git a/cmd/modcli/cmd/generate_module_test.go b/cmd/modcli/cmd/generate_module_test.go index 3e6ae05f..a4b3ef12 100644 --- a/cmd/modcli/cmd/generate_module_test.go +++ b/cmd/modcli/cmd/generate_module_test.go @@ -14,7 +14,7 @@ import ( "encoding/json" - "github.com/CrisisTextLine/modular/cmd/modcli/cmd" + "github.com/GoCodeAlone/modular/cmd/modcli/cmd" "github.com/pelletier/go-toml/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -825,7 +825,7 @@ func TestGenerateModuleCompiles(t *testing.T) { go 1.21 require ( - github.com/CrisisTextLine/modular v1 + github.com/GoCodeAlone/modular v1 ) ` @@ -840,7 +840,7 @@ import ( "log" "log/slog" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Example function showing how to use the module diff --git a/cmd/modcli/cmd/mock_io_test.go b/cmd/modcli/cmd/mock_io_test.go index 82105054..db299f85 100644 --- a/cmd/modcli/cmd/mock_io_test.go +++ b/cmd/modcli/cmd/mock_io_test.go @@ -5,7 +5,7 @@ import ( "io" "testing" - "github.com/CrisisTextLine/modular/cmd/modcli/cmd" + "github.com/GoCodeAlone/modular/cmd/modcli/cmd" ) // MockReader is a wrapper around a bytes.Buffer that also implements terminal.FileReader diff --git a/cmd/modcli/cmd/root_test.go b/cmd/modcli/cmd/root_test.go index 8b9d825d..5143b238 100644 --- a/cmd/modcli/cmd/root_test.go +++ b/cmd/modcli/cmd/root_test.go @@ -4,7 +4,7 @@ import ( "bytes" "testing" - "github.com/CrisisTextLine/modular/cmd/modcli/cmd" + "github.com/GoCodeAlone/modular/cmd/modcli/cmd" "github.com/stretchr/testify/assert" ) diff --git a/cmd/modcli/cmd/simple_module_test.go b/cmd/modcli/cmd/simple_module_test.go index 81504bbf..ccab45a1 100644 --- a/cmd/modcli/cmd/simple_module_test.go +++ b/cmd/modcli/cmd/simple_module_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular/cmd/modcli/cmd" + "github.com/GoCodeAlone/modular/cmd/modcli/cmd" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/cmd/modcli/cmd/testdata/golden/goldenmodule/README.md b/cmd/modcli/cmd/testdata/golden/goldenmodule/README.md index 73064e53..a1350f85 100644 --- a/cmd/modcli/cmd/testdata/golden/goldenmodule/README.md +++ b/cmd/modcli/cmd/testdata/golden/goldenmodule/README.md @@ -1,6 +1,6 @@ # GoldenModule Module -A module for the [Modular](https://github.com/CrisisTextLine/modular) framework. +A module for the [Modular](https://github.com/GoCodeAlone/modular) framework. ## Overview @@ -24,7 +24,7 @@ go get github.com/yourusername/goldenmodule package main import ( - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/yourusername/goldenmodule" "log/slog" "os" diff --git a/cmd/modcli/cmd/testdata/golden/goldenmodule/go.mod b/cmd/modcli/cmd/testdata/golden/goldenmodule/go.mod index bc611baa..9095fb4b 100644 --- a/cmd/modcli/cmd/testdata/golden/goldenmodule/go.mod +++ b/cmd/modcli/cmd/testdata/golden/goldenmodule/go.mod @@ -3,7 +3,7 @@ module example.com/goldenmodule go 1.25 require ( - github.com/CrisisTextLine/modular v1.6.0 + github.com/GoCodeAlone/modular v1.6.0 github.com/stretchr/testify v1.11.1 ) @@ -22,4 +22,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../../../../../../ +replace github.com/GoCodeAlone/modular => ../../../../../../ diff --git a/cmd/modcli/cmd/testdata/golden/goldenmodule/mock_test.go b/cmd/modcli/cmd/testdata/golden/goldenmodule/mock_test.go index 8c8f4b7b..126504ee 100644 --- a/cmd/modcli/cmd/testdata/golden/goldenmodule/mock_test.go +++ b/cmd/modcli/cmd/testdata/golden/goldenmodule/mock_test.go @@ -5,7 +5,7 @@ import ( "reflect" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // MockApplication implements the modular.Application interface for testing diff --git a/cmd/modcli/cmd/testdata/golden/goldenmodule/module.go b/cmd/modcli/cmd/testdata/golden/goldenmodule/module.go index d2ea0c33..219a8c8d 100644 --- a/cmd/modcli/cmd/testdata/golden/goldenmodule/module.go +++ b/cmd/modcli/cmd/testdata/golden/goldenmodule/module.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "log/slog" ) diff --git a/cmd/modcli/cmd/testdata/golden/goldenmodule/module_test.go b/cmd/modcli/cmd/testdata/golden/goldenmodule/module_test.go index 61151030..b181dfb5 100644 --- a/cmd/modcli/cmd/testdata/golden/goldenmodule/module_test.go +++ b/cmd/modcli/cmd/testdata/golden/goldenmodule/module_test.go @@ -3,7 +3,7 @@ package goldenmodule import ( "context" "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "testing" diff --git a/cmd/modcli/go.mod b/cmd/modcli/go.mod index d69fa49b..baa2ed13 100644 --- a/cmd/modcli/go.mod +++ b/cmd/modcli/go.mod @@ -1,4 +1,4 @@ -module github.com/CrisisTextLine/modular/cmd/modcli +module github.com/GoCodeAlone/modular/cmd/modcli go 1.25 diff --git a/cmd/modcli/internal/git/git.go b/cmd/modcli/internal/git/git.go index 15193fc8..ad5a7b77 100644 --- a/cmd/modcli/internal/git/git.go +++ b/cmd/modcli/internal/git/git.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/CrisisTextLine/modular/cmd/modcli/internal/contract" + "github.com/GoCodeAlone/modular/cmd/modcli/internal/contract" ) // GitHelper provides functionality to work with git repositories for contract extraction diff --git a/cmd/modcli/internal/git/git_test.go b/cmd/modcli/internal/git/git_test.go index 0d3cf966..bdd761d2 100644 --- a/cmd/modcli/internal/git/git_test.go +++ b/cmd/modcli/internal/git/git_test.go @@ -6,7 +6,7 @@ import ( "regexp" "testing" - "github.com/CrisisTextLine/modular/cmd/modcli/internal/contract" + "github.com/GoCodeAlone/modular/cmd/modcli/internal/contract" ) func TestGitHelper_NewGitHelper(t *testing.T) { diff --git a/cmd/modcli/main.go b/cmd/modcli/main.go index 61cdc9ec..28da7a4b 100644 --- a/cmd/modcli/main.go +++ b/cmd/modcli/main.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "github.com/CrisisTextLine/modular/cmd/modcli/cmd" + "github.com/GoCodeAlone/modular/cmd/modcli/cmd" ) func main() { diff --git a/cmd/modcli/main_test.go b/cmd/modcli/main_test.go index 567ded5b..e17b7838 100644 --- a/cmd/modcli/main_test.go +++ b/cmd/modcli/main_test.go @@ -6,7 +6,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular/cmd/modcli/cmd" + "github.com/GoCodeAlone/modular/cmd/modcli/cmd" ) func TestMainVersionFlag(t *testing.T) { diff --git a/complex_dependencies_test.go b/complex_dependencies_test.go index a3fc413a..5bbc3a47 100644 --- a/complex_dependencies_test.go +++ b/complex_dependencies_test.go @@ -291,13 +291,13 @@ func (m *APIModule) RequiresServices() []ServiceDependency { Name: "cache", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*CacheService)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[CacheService](), }, { Name: "database", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*DatabaseService)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[DatabaseService](), }, } } @@ -392,7 +392,7 @@ func (m *AuthModule) RequiresServices() []ServiceDependency { Name: "logger-service", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*LoggingService)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[LoggingService](), }, } } diff --git a/config_direct_field_tracking_test.go b/config_direct_field_tracking_test.go index a293df56..21c612eb 100644 --- a/config_direct_field_tracking_test.go +++ b/config_direct_field_tracking_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/config_feeders.go b/config_feeders.go index 1c3f6e00..5afce7fd 100644 --- a/config_feeders.go +++ b/config_feeders.go @@ -1,13 +1,13 @@ package modular import ( - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // Feeder defines the interface for configuration feeders that provide configuration data. type Feeder interface { // Feed gets a struct and feeds it using configuration data. - Feed(structure interface{}) error + Feed(structure any) error } // ConfigFeeders provides a default set of configuration feeders for common use cases @@ -18,14 +18,14 @@ var ConfigFeeders = []Feeder{ // ComplexFeeder extends the basic Feeder interface with additional functionality for complex configuration scenarios type ComplexFeeder interface { Feeder - FeedKey(string, interface{}) error + FeedKey(string, any) error } // InstanceAwareFeeder provides functionality for feeding multiple instances of the same configuration type type InstanceAwareFeeder interface { ComplexFeeder // FeedInstances feeds multiple instances from a map[string]ConfigType - FeedInstances(instances interface{}) error + FeedInstances(instances any) error } // VerboseAwareFeeder provides functionality for verbose debug logging during configuration feeding @@ -47,7 +47,7 @@ type ModuleAwareFeeder interface { // FeedWithModuleContext feeds configuration with module context information. // The moduleName parameter provides the name of the module whose configuration // is being processed, allowing the feeder to customize its behavior accordingly. - FeedWithModuleContext(structure interface{}, moduleName string) error + FeedWithModuleContext(structure any, moduleName string) error } // PrioritizedFeeder extends the Feeder interface with priority control. diff --git a/config_field_tracking.go b/config_field_tracking.go index 1eef7e2f..57588491 100644 --- a/config_field_tracking.go +++ b/config_field_tracking.go @@ -23,7 +23,7 @@ type FieldPopulation struct { FeederType string // Type of feeder that populated it SourceType string // Type of source (env, yaml, etc.) SourceKey string // Source key that was used (e.g., "DB_PRIMARY_DSN") - Value interface{} // Value that was set + Value any // Value that was set InstanceKey string // Instance key for instance-aware fields SearchKeys []string // All keys that were searched for this field FoundKey string // The key that was actually found @@ -132,8 +132,8 @@ func (t *DefaultFieldTracker) GetPopulationsBySource(sourceType string) []FieldP // StructStateDiffer captures before/after states to determine field changes type StructStateDiffer struct { - beforeState map[string]interface{} - afterState map[string]interface{} + beforeState map[string]any + afterState map[string]any tracker FieldTracker logger Logger } @@ -141,15 +141,15 @@ type StructStateDiffer struct { // NewStructStateDiffer creates a new struct state differ func NewStructStateDiffer(tracker FieldTracker, logger Logger) *StructStateDiffer { return &StructStateDiffer{ - beforeState: make(map[string]interface{}), - afterState: make(map[string]interface{}), + beforeState: make(map[string]any), + afterState: make(map[string]any), tracker: tracker, logger: logger, } } // CaptureBeforeState captures the state before feeder processing -func (d *StructStateDiffer) CaptureBeforeState(structure interface{}, prefix string) { +func (d *StructStateDiffer) CaptureBeforeState(structure any, prefix string) { d.captureState(structure, prefix, d.beforeState) if d.logger != nil { d.logger.Debug("Captured before state", "prefix", prefix, "fieldCount", len(d.beforeState)) @@ -157,7 +157,7 @@ func (d *StructStateDiffer) CaptureBeforeState(structure interface{}, prefix str } // CaptureAfterStateAndDiff captures the state after feeder processing and computes diffs -func (d *StructStateDiffer) CaptureAfterStateAndDiff(structure interface{}, prefix string, feederType, sourceType string) { +func (d *StructStateDiffer) CaptureAfterStateAndDiff(structure any, prefix string, feederType, sourceType string) { d.captureState(structure, prefix, d.afterState) if d.logger != nil { d.logger.Debug("Captured after state", "prefix", prefix, "fieldCount", len(d.afterState)) @@ -168,9 +168,9 @@ func (d *StructStateDiffer) CaptureAfterStateAndDiff(structure interface{}, pref } // captureState recursively captures all field values in a structure -func (d *StructStateDiffer) captureState(structure interface{}, prefix string, state map[string]interface{}) { +func (d *StructStateDiffer) captureState(structure any, prefix string, state map[string]any) { rv := reflect.ValueOf(structure) - if rv.Kind() == reflect.Ptr { + if rv.Kind() == reflect.Pointer { if rv.IsNil() { return } @@ -185,7 +185,7 @@ func (d *StructStateDiffer) captureState(structure interface{}, prefix string, s } // captureStructFields recursively captures all field values in a struct -func (d *StructStateDiffer) captureStructFields(rv reflect.Value, prefix string, state map[string]interface{}) { +func (d *StructStateDiffer) captureStructFields(rv reflect.Value, prefix string, state map[string]any) { rt := rv.Type() for i := 0; i < rv.NumField(); i++ { @@ -204,7 +204,7 @@ func (d *StructStateDiffer) captureStructFields(rv reflect.Value, prefix string, switch field.Kind() { case reflect.Struct: d.captureStructFields(field, fieldPath, state) - case reflect.Ptr: + case reflect.Pointer: if !field.IsNil() && field.Elem().Kind() == reflect.Struct { d.captureStructFields(field.Elem(), fieldPath, state) } else if !field.IsNil() { @@ -217,7 +217,7 @@ func (d *StructStateDiffer) captureStructFields(rv reflect.Value, prefix string, mapFieldPath := fieldPath + "." + key.String() if mapValue.Kind() == reflect.Struct { d.captureStructFields(mapValue, mapFieldPath, state) - } else if mapValue.Kind() == reflect.Ptr && !mapValue.IsNil() && mapValue.Elem().Kind() == reflect.Struct { + } else if mapValue.Kind() == reflect.Pointer && !mapValue.IsNil() && mapValue.Elem().Kind() == reflect.Struct { d.captureStructFields(mapValue.Elem(), mapFieldPath, state) } else { state[mapFieldPath] = mapValue.Interface() @@ -284,6 +284,6 @@ func (d *StructStateDiffer) computeAndRecordDiffs(feederType, sourceType, instan // Reset clears the captured states for reuse func (d *StructStateDiffer) Reset() { - d.beforeState = make(map[string]interface{}) - d.afterState = make(map[string]interface{}) + d.beforeState = make(map[string]any) + d.afterState = make(map[string]any) } diff --git a/config_field_tracking_implementation_test.go b/config_field_tracking_implementation_test.go index 325da524..af66e2bd 100644 --- a/config_field_tracking_implementation_test.go +++ b/config_field_tracking_implementation_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/config_field_tracking_test.go b/config_field_tracking_test.go index 4ff1c42b..4518063f 100644 --- a/config_field_tracking_test.go +++ b/config_field_tracking_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -311,7 +311,7 @@ func TestDetailedInstanceAwareFieldTracking(t *testing.T) { // Create mock logger to capture verbose output mockLogger := new(MockLogger) - debugLogs := make([][]interface{}, 0) + debugLogs := make([][]any, 0) mockLogger.On("Debug", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { debugLogs = append(debugLogs, args) }).Return() @@ -396,7 +396,8 @@ func TestDetailedInstanceAwareFieldTracking(t *testing.T) { var secondaryDriverPop, secondaryDSNPop, secondaryMaxConnsPop *FieldPopulation for _, fp := range tracker.FieldPopulations { - if fp.InstanceKey == "primary" { + switch fp.InstanceKey { +case "primary": switch fp.FieldName { case "Driver": primaryDriverPop = &fp @@ -405,7 +406,7 @@ func TestDetailedInstanceAwareFieldTracking(t *testing.T) { case "MaxConns": primaryMaxConnsPop = &fp } - } else if fp.InstanceKey == "secondary" { + case "secondary": switch fp.FieldName { case "Driver": secondaryDriverPop = &fp @@ -470,7 +471,7 @@ func TestConfigDiffBasedFieldTracking(t *testing.T) { tests := []struct { name string envVars map[string]string - expectedFieldDiffs map[string]interface{} // field path -> expected new value + expectedFieldDiffs map[string]any // field path -> expected new value }{ { name: "basic field diff tracking", @@ -478,7 +479,7 @@ func TestConfigDiffBasedFieldTracking(t *testing.T) { "APP_NAME": "Test App", "APP_DEBUG": "true", }, - expectedFieldDiffs: map[string]interface{}{ + expectedFieldDiffs: map[string]any{ "AppName": "Test App", "Debug": true, }, @@ -555,7 +556,7 @@ func TestVerboseDebugFieldVisibility(t *testing.T) { mockLogger := new(MockLogger) // Capture all debug log calls - var debugCalls [][]interface{} + var debugCalls [][]any mockLogger.On("Debug", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { debugCalls = append(debugCalls, args) }).Return() @@ -598,17 +599,17 @@ func TestVerboseDebugFieldVisibility(t *testing.T) { // StructState represents the state of a struct at a point in time type StructState struct { - Fields map[string]interface{} // field path -> value + Fields map[string]any // field path -> value } // captureStructState captures the current state of all fields in a struct -func captureStructState(structure interface{}) *StructState { +func captureStructState(structure any) *StructState { state := &StructState{ - Fields: make(map[string]interface{}), + Fields: make(map[string]any), } rv := reflect.ValueOf(structure) - if rv.Kind() == reflect.Ptr { + if rv.Kind() == reflect.Pointer { rv = rv.Elem() } @@ -617,7 +618,7 @@ func captureStructState(structure interface{}) *StructState { } // captureStructFields recursively captures all field values -func captureStructFields(rv reflect.Value, prefix string, fields map[string]interface{}) { +func captureStructFields(rv reflect.Value, prefix string, fields map[string]any) { rt := rv.Type() for i := 0; i < rv.NumField(); i++ { @@ -632,7 +633,7 @@ func captureStructFields(rv reflect.Value, prefix string, fields map[string]inte switch field.Kind() { case reflect.Struct: captureStructFields(field, fieldPath, fields) - case reflect.Ptr: + case reflect.Pointer: if !field.IsNil() && field.Elem().Kind() == reflect.Struct { captureStructFields(field.Elem(), fieldPath, fields) } else if !field.IsNil() { @@ -662,8 +663,8 @@ func captureStructFields(rv reflect.Value, prefix string, fields map[string]inte } // computeFieldDiffs computes the differences between two struct states -func computeFieldDiffs(before, after *StructState) map[string]interface{} { - diffs := make(map[string]interface{}) +func computeFieldDiffs(before, after *StructState) map[string]any { + diffs := make(map[string]any) // Find fields that changed for fieldPath, afterValue := range after.Fields { diff --git a/config_full_flow_field_tracking_test.go b/config_full_flow_field_tracking_test.go index 2a45a303..04a98996 100644 --- a/config_full_flow_field_tracking_test.go +++ b/config_full_flow_field_tracking_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -46,7 +46,7 @@ func createTestConfig() (*Config, FieldTracker, *MockLogger) { } // clearTestEnvironment clears all environment variables that could affect our tests -func clearTestEnvironment(t *testing.T) { +func clearTestEnvironment(_ *testing.T) { // Clear all potential test environment variables testEnvVars := []string{ // Test 1 variables diff --git a/config_provider.go b/config_provider.go index 091577f5..89b329f3 100644 --- a/config_provider.go +++ b/config_provider.go @@ -314,7 +314,7 @@ type Config struct { Feeders []Feeder // StructKeys maps struct identifiers to their configuration objects. // Used internally to track which configuration structures have been processed. - StructKeys map[string]interface{} + StructKeys map[string]any // VerboseDebug enables detailed logging during configuration processing VerboseDebug bool // Logger is used for verbose debug logging @@ -336,7 +336,7 @@ type Config struct { func NewConfig() *Config { return &Config{ Feeders: make([]Feeder, 0), - StructKeys: make(map[string]interface{}), + StructKeys: make(map[string]any), VerboseDebug: false, Logger: nil, FieldTracker: NewDefaultFieldTracker(), @@ -396,7 +396,7 @@ func (c *Config) AddFeeder(feeder Feeder) *Config { } // AddStructKey adds a structure with a key to the configuration -func (c *Config) AddStructKey(key string, target interface{}) *Config { +func (c *Config) AddStructKey(key string, target any) *Config { c.StructKeys[key] = target return c } @@ -420,7 +420,7 @@ func (c *Config) SetFieldTracker(tracker FieldTracker) *Config { // FeedWithModuleContext feeds a single configuration structure with module context information // This allows module-aware feeders to customize their behavior based on the module name -func (c *Config) FeedWithModuleContext(target interface{}, moduleName string) error { +func (c *Config) FeedWithModuleContext(target any, moduleName string) error { if c.VerboseDebug && c.Logger != nil { c.Logger.Debug("Starting module-aware config feed", "targetType", reflect.TypeOf(target), "moduleName", moduleName, "feedersCount", len(c.Feeders)) } @@ -953,7 +953,7 @@ func applyInstanceAwareFeeding(app *StdApplication, tempConfigs map[string]confi // Get the config from the temporary config that was just fed with YAML/ENV data configInfo := tempConfigs[sectionKey] - var tempConfig interface{} + var tempConfig any if configInfo.isPtr { tempConfig = configInfo.tempVal.Interface() } else { @@ -1032,13 +1032,13 @@ type configInfo struct { } // createTempConfig creates a temporary config for feeding values -func createTempConfig(cfg any) (interface{}, configInfo, error) { +func createTempConfig(cfg any) (any, configInfo, error) { if cfg == nil { return nil, configInfo{}, ErrConfigNil } cfgValue := reflect.ValueOf(cfg) - isPtr := cfgValue.Kind() == reflect.Ptr + isPtr := cfgValue.Kind() == reflect.Pointer var targetType reflect.Type var sourceValue reflect.Value @@ -1087,13 +1087,13 @@ func DeepCopyConfig(cfg any) (any, error) { // // This is useful when you need to ensure that modifications to the temporary config // during processing will not affect the original configuration. -func createTempConfigDeep(cfg any) (interface{}, configInfo, error) { +func createTempConfigDeep(cfg any) (any, configInfo, error) { if cfg == nil { return nil, configInfo{}, ErrConfigNil } cfgValue := reflect.ValueOf(cfg) - isPtr := cfgValue.Kind() == reflect.Ptr + isPtr := cfgValue.Kind() == reflect.Pointer var targetType reflect.Type var sourceValue reflect.Value @@ -1133,7 +1133,7 @@ func deepCopyValue(dst, src reflect.Value) { } switch src.Kind() { - case reflect.Ptr: + case reflect.Pointer: if src.IsNil() { return } diff --git a/config_provider_app_loading_test.go b/config_provider_app_loading_test.go index d5caaa22..92ba8ea9 100644 --- a/config_provider_app_loading_test.go +++ b/config_provider_app_loading_test.go @@ -139,8 +139,8 @@ func Test_loadAppConfig(t *testing.T) { mockLogger := new(MockLogger) mockLogger.On("Debug", "Creating new provider with updated config (original was non-pointer)", - []interface{}(nil)).Return() - mockLogger.On("Debug", "Creating new provider for section", []interface{}{"section", "section1"}).Return() + []any(nil)).Return() + mockLogger.On("Debug", "Creating new provider for section", []any{"section", "section1"}).Return() mockLogger.On("Debug", "Added main config for loading", mock.Anything).Return() mockLogger.On("Debug", "Added section config for loading", mock.Anything).Return() mockLogger.On("Debug", "Updated main config", mock.Anything).Return() diff --git a/config_provider_basic_test.go b/config_provider_basic_test.go index b205f0c8..b1c2eedd 100644 --- a/config_provider_basic_test.go +++ b/config_provider_basic_test.go @@ -22,7 +22,7 @@ type MockComplexFeeder struct { mock.Mock } -func (m *MockComplexFeeder) Feed(structure interface{}) error { +func (m *MockComplexFeeder) Feed(structure any) error { args := m.Called(structure) if err := args.Error(0); err != nil { return fmt.Errorf("mock feeder error: %w", err) @@ -30,7 +30,7 @@ func (m *MockComplexFeeder) Feed(structure interface{}) error { return nil } -func (m *MockComplexFeeder) FeedKey(key string, target interface{}) error { +func (m *MockComplexFeeder) FeedKey(key string, target any) error { args := m.Called(key, target) if err := args.Error(0); err != nil { return fmt.Errorf("mock feeder key error: %w", err) diff --git a/config_provider_temp_config_test.go b/config_provider_temp_config_test.go index 18c49e63..9ec0e27c 100644 --- a/config_provider_temp_config_test.go +++ b/config_provider_temp_config_test.go @@ -156,7 +156,7 @@ func Test_updateConfig(t *testing.T) { mockLogger := new(MockLogger) mockLogger.On("Debug", "Creating new provider with updated config (original was non-pointer)", - []interface{}(nil)).Return() + []any(nil)).Return() app := &StdApplication{ logger: mockLogger, cfgProvider: NewStdConfigProvider(originalCfg), @@ -209,7 +209,7 @@ func Test_updateSectionConfig(t *testing.T) { tempCfgPtr.(*testSectionCfg).Name = "new" mockLogger := new(MockLogger) - mockLogger.On("Debug", "Creating new provider for section", []interface{}{"section", "test"}).Return() + mockLogger.On("Debug", "Creating new provider for section", []any{"section", "test"}).Return() app := &StdApplication{ logger: mockLogger, @@ -288,7 +288,7 @@ func TestDeepCopyValue_Maps(t *testing.T) { t.Run("nil map", func(t *testing.T) { var src map[string]string = nil - dst := reflect.New(reflect.TypeOf(map[string]string{})).Elem() + dst := reflect.New(reflect.TypeFor[map[string]string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) // For nil map, deepCopyValue returns early without modifying dst @@ -304,7 +304,7 @@ func TestDeepCopyValue_Slices(t *testing.T) { t.Run("simple slice of integers", func(t *testing.T) { src := []int{1, 2, 3, 4, 5} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[[]int]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstSlice := dst.Interface().([]int) @@ -318,7 +318,7 @@ func TestDeepCopyValue_Slices(t *testing.T) { t.Run("slice of strings", func(t *testing.T) { src := []string{"hello", "world"} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[[]string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstSlice := dst.Interface().([]string) @@ -349,7 +349,7 @@ func TestDeepCopyValue_Slices(t *testing.T) { t.Run("nil slice", func(t *testing.T) { var src []string = nil - dst := reflect.New(reflect.TypeOf([]string{})).Elem() + dst := reflect.New(reflect.TypeFor[[]string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) // For nil slice, deepCopyValue returns early without modifying dst @@ -366,7 +366,7 @@ func TestDeepCopyValue_Pointers(t *testing.T) { str := "original" src := &str - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[*string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstPtr := dst.Interface().(*string) @@ -385,7 +385,7 @@ func TestDeepCopyValue_Pointers(t *testing.T) { src := &TestStruct{Name: "test", Value: 42} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[*TestStruct]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstPtr := dst.Interface().(*TestStruct) @@ -400,7 +400,7 @@ func TestDeepCopyValue_Pointers(t *testing.T) { t.Run("nil pointer", func(t *testing.T) { var src *string = nil - dst := reflect.New(reflect.TypeOf((*string)(nil))).Elem() + dst := reflect.New(reflect.TypeFor[*string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) // For nil pointer, deepCopyValue returns early without modifying dst @@ -421,7 +421,7 @@ func TestDeepCopyValue_Structs(t *testing.T) { src := SimpleStruct{Name: "John", Age: 30} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[SimpleStruct]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstStruct := dst.Interface().(SimpleStruct) @@ -440,7 +440,7 @@ func TestDeepCopyValue_Structs(t *testing.T) { Settings: map[string]string{"key1": "value1", "key2": "value2"}, } - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[ConfigStruct]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstStruct := dst.Interface().(ConfigStruct) @@ -463,7 +463,7 @@ func TestDeepCopyValue_Structs(t *testing.T) { Items: []string{"a", "b", "c"}, } - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[ListStruct]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstStruct := dst.Interface().(ListStruct) @@ -489,7 +489,7 @@ func TestDeepCopyValue_Structs(t *testing.T) { Inner: InnerStruct{Value: 42}, } - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[OuterStruct]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstStruct := dst.Interface().(OuterStruct) @@ -522,7 +522,7 @@ func TestDeepCopyValue_BasicTypes(t *testing.T) { tests := []struct { name string - value interface{} + value any }{ {"int", 42}, {"int64", int64(123456789)}, @@ -567,7 +567,7 @@ func TestDeepCopyValue_ComplexStructures(t *testing.T) { AllowedIPs: []string{"192.168.1.1", "10.0.0.1"}, } - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[ComplexConfig]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstConfig := dst.Interface().(ComplexConfig) @@ -598,7 +598,7 @@ func TestDeepCopyValue_Arrays(t *testing.T) { t.Run("array of integers", func(t *testing.T) { src := [5]int{1, 2, 3, 4, 5} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[[5]int]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstArray := dst.Interface().([5]int) @@ -612,7 +612,7 @@ func TestDeepCopyValue_Arrays(t *testing.T) { t.Run("array of strings", func(t *testing.T) { src := [3]string{"foo", "bar", "baz"} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[[3]string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstArray := dst.Interface().([3]string) @@ -626,7 +626,7 @@ func TestDeepCopyValue_Arrays(t *testing.T) { str1, str2 := "value1", "value2" src := [2]*string{&str1, &str2} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[[2]*string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstArray := dst.Interface().([2]*string) @@ -644,7 +644,7 @@ func TestDeepCopyValue_Interfaces(t *testing.T) { t.Parallel() t.Run("interface with concrete string", func(t *testing.T) { - var src interface{} = "hello" + var src any = "hello" dst := reflect.New(reflect.TypeOf(src)).Elem() deepCopyValue(dst, reflect.ValueOf(src)) @@ -654,7 +654,7 @@ func TestDeepCopyValue_Interfaces(t *testing.T) { }) t.Run("interface with concrete map", func(t *testing.T) { - var src interface{} = map[string]int{"a": 1, "b": 2} + var src any = map[string]int{"a": 1, "b": 2} dst := reflect.New(reflect.TypeOf(src)).Elem() deepCopyValue(dst, reflect.ValueOf(src)) @@ -672,7 +672,7 @@ func TestDeepCopyValue_Interfaces(t *testing.T) { t.Run("struct with interface field", func(t *testing.T) { type ConfigWithInterface struct { Name string - Data interface{} + Data any } src := ConfigWithInterface{ @@ -696,7 +696,7 @@ func TestDeepCopyValue_Interfaces(t *testing.T) { t.Run("struct with nil interface field", func(t *testing.T) { type ConfigWithInterface struct { Name string - Data interface{} + Data any } src := ConfigWithInterface{ @@ -718,7 +718,7 @@ func TestDeepCopyValue_Interfaces(t *testing.T) { Data map[string]string } - var src interface{} = TestStruct{ + var src any = TestStruct{ Value: 42, Data: map[string]string{"key": "value"}, } @@ -746,7 +746,7 @@ func TestDeepCopyValue_Channels(t *testing.T) { src <- 42 src <- 100 - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[chan int]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstChan := dst.Interface().(chan int) @@ -759,7 +759,7 @@ func TestDeepCopyValue_Channels(t *testing.T) { t.Run("nil channel", func(t *testing.T) { var src chan string = nil - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[chan string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstChan := dst.Interface().(chan string) @@ -809,7 +809,7 @@ func TestDeepCopyValue_Invalid(t *testing.T) { t.Run("invalid value", func(t *testing.T) { var src reflect.Value // Invalid (zero value) - dst := reflect.New(reflect.TypeOf("")).Elem() + dst := reflect.New(reflect.TypeFor[string]()).Elem() // Should not panic require.NotPanics(t, func() { diff --git a/config_provider_test.go b/config_provider_test.go index fe59b1f4..049ed3a3 100644 --- a/config_provider_test.go +++ b/config_provider_test.go @@ -151,15 +151,13 @@ func TestImmutableConfigProvider(t *testing.T) { errors := make(chan error, 100) // 100 concurrent readers - for i := 0; i < 100; i++ { - wg.Add(1) - go func() { - defer wg.Done() + for range 100 { + wg.Go(func() { cfg := provider.GetConfig().(*TestConfig) if cfg == nil { errors <- fmt.Errorf("config is nil") } - }() + }) } wg.Wait() @@ -178,26 +176,24 @@ func TestImmutableConfigProvider(t *testing.T) { errors := make(chan error, 100) // 50 concurrent readers - for i := 0; i < 50; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for j := 0; j < 100; j++ { + for range 50 { + wg.Go(func() { + for range 100 { cfg := provider.GetConfig().(*TestConfig) if cfg == nil { errors <- ErrConfigNil return } } - }() + }) } // 10 concurrent updaters - for i := 0; i < 10; i++ { + for i := range 10 { wg.Add(1) go func(id int) { defer wg.Done() - for j := 0; j < 10; j++ { + for j := range 10 { newCfg := &TestConfig{ Host: "example.com", Port: 8080 + id*100 + j, @@ -299,15 +295,13 @@ func TestCopyOnWriteConfigProvider(t *testing.T) { errors := make(chan error, 100) // 100 concurrent readers - for i := 0; i < 100; i++ { - wg.Add(1) - go func() { - defer wg.Done() + for range 100 { + wg.Go(func() { cfg := provider.GetConfig().(*TestConfig) if cfg == nil { errors <- fmt.Errorf("config is nil") } - }() + }) } wg.Wait() @@ -331,7 +325,7 @@ func TestCopyOnWriteConfigProvider(t *testing.T) { errors := make(chan error, 50) // 50 concurrent mutable copy requests - for i := 0; i < 50; i++ { + for i := range 50 { wg.Add(1) go func(id int) { defer wg.Done() diff --git a/config_provider_verbose_test.go b/config_provider_verbose_test.go index d9a719df..06b6d9fe 100644 --- a/config_provider_verbose_test.go +++ b/config_provider_verbose_test.go @@ -14,7 +14,7 @@ type MockVerboseAwareFeeder struct { mock.Mock } -func (m *MockVerboseAwareFeeder) Feed(structure interface{}) error { +func (m *MockVerboseAwareFeeder) Feed(structure any) error { args := m.Called(structure) if err := args.Error(0); err != nil { return fmt.Errorf("mock feeder error: %w", err) diff --git a/config_validation.go b/config_validation.go index ceced11d..49b678d0 100644 --- a/config_validation.go +++ b/config_validation.go @@ -17,8 +17,7 @@ const ( // Struct tag keys tagDefault = "default" tagRequired = "required" - tagValidate = "validate" - tagDesc = "desc" // Used for generating sample config and documentation + ) // ConfigValidator is an interface for configuration validation. @@ -68,13 +67,13 @@ type ConfigValidator interface { // // This function is automatically called by the configuration loading system // before validation, but can also be called manually if needed. -func ProcessConfigDefaults(cfg interface{}) error { +func ProcessConfigDefaults(cfg any) error { if cfg == nil { return ErrConfigNil } v := reflect.ValueOf(cfg) - if v.Kind() != reflect.Ptr || v.IsNil() { + if v.Kind() != reflect.Pointer || v.IsNil() { return ErrConfigNotPointer } @@ -108,7 +107,7 @@ func processStructDefaults(v reflect.Value) error { } // Handle pointers to structs - but only if they're already non-nil - if field.Kind() == reflect.Ptr && field.Type().Elem().Kind() == reflect.Struct { + if field.Kind() == reflect.Pointer && field.Type().Elem().Kind() == reflect.Struct { // Don't automatically initialize nil struct pointers // (the previous behavior was automatically creating them) if !field.IsNil() { @@ -136,13 +135,13 @@ func processStructDefaults(v reflect.Value) error { // ValidateConfigRequired checks all struct fields with `required:"true"` tag // and verifies they are not zero/empty values -func ValidateConfigRequired(cfg interface{}) error { +func ValidateConfigRequired(cfg any) error { if cfg == nil { return ErrConfigNil } v := reflect.ValueOf(cfg) - if v.Kind() != reflect.Ptr || v.IsNil() { + if v.Kind() != reflect.Pointer || v.IsNil() { return ErrConfigNotPointer } @@ -186,7 +185,7 @@ func validateRequiredFields(v reflect.Value, prefix string, errors *[]string) { } // Handle pointers to structs - if field.Kind() == reflect.Ptr && field.Type().Elem().Kind() == reflect.Struct { + if field.Kind() == reflect.Pointer && field.Type().Elem().Kind() == reflect.Struct { if !field.IsNil() { validateRequiredFields(field.Elem(), fieldName, errors) } else if isFieldRequired(&fieldType) { @@ -221,7 +220,7 @@ func isZeroValue(v reflect.Value) bool { return v.Uint() == 0 case reflect.Float32, reflect.Float64: return v.Float() == 0 - case reflect.Interface, reflect.Ptr: + case reflect.Interface, reflect.Pointer: return v.IsNil() case reflect.Invalid: return true @@ -239,7 +238,7 @@ func isZeroValue(v reflect.Value) bool { // setDefaultValue sets a default value from a string to the proper field type func setDefaultValue(field reflect.Value, defaultVal string) error { // Special handling for time.Duration type - if field.Type() == reflect.TypeOf(time.Duration(0)) { + if field.Type() == reflect.TypeFor[time.Duration]() { return setDefaultDuration(field, defaultVal) } @@ -262,7 +261,7 @@ func setDefaultValue(field reflect.Value, defaultVal string) error { case reflect.Map: return setDefaultMap(field, defaultVal) case reflect.Invalid, reflect.Complex64, reflect.Complex128, reflect.Array, - reflect.Chan, reflect.Func, reflect.Interface, reflect.Ptr, reflect.Struct, + reflect.Chan, reflect.Func, reflect.Interface, reflect.Pointer, reflect.Struct, reflect.UnsafePointer: return handleUnsupportedDefaultType(kind) default: @@ -289,7 +288,7 @@ func handleUnsupportedDefaultType(kind reflect.Kind) error { return fmt.Errorf("%w: functions not supported", ErrUnsupportedTypeForDefault) case reflect.Interface: return fmt.Errorf("%w: interfaces not supported", ErrUnsupportedTypeForDefault) - case reflect.Ptr: + case reflect.Pointer: return fmt.Errorf("%w: pointers not supported", ErrUnsupportedTypeForDefault) case reflect.Struct: return fmt.Errorf("%w: structs not supported", ErrUnsupportedTypeForDefault) @@ -391,7 +390,7 @@ func setDefaultInt(field reflect.Value, i int64) error { case reflect.Invalid, reflect.Bool, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128, reflect.Array, reflect.Chan, reflect.Func, reflect.Interface, - reflect.Map, reflect.Ptr, reflect.Slice, reflect.String, reflect.Struct, reflect.UnsafePointer: + reflect.Map, reflect.Pointer, reflect.Slice, reflect.String, reflect.Struct, reflect.UnsafePointer: return fmt.Errorf("%w: cannot set int value to %s", ErrIncompatibleFieldKind, field.Kind()) default: return fmt.Errorf("%w: cannot set int value to %s", ErrIncompatibleFieldKind, field.Kind()) @@ -408,7 +407,7 @@ func setDefaultUint(field reflect.Value, u uint64) error { return nil case reflect.Invalid, reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128, - reflect.Array, reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, + reflect.Array, reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice, reflect.String, reflect.Struct, reflect.UnsafePointer: return fmt.Errorf("%w: cannot set uint value to %s", ErrIncompatibleFieldKind, field.Kind()) default: @@ -428,7 +427,7 @@ func setDefaultFloat(field reflect.Value, f float64) error { case reflect.Invalid, reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, reflect.Complex64, reflect.Complex128, reflect.Array, reflect.Chan, - reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice, reflect.String, + reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice, reflect.String, reflect.Struct, reflect.UnsafePointer: return fmt.Errorf("%w: cannot set float value to %s", ErrIncompatibleFieldKind, field.Kind()) default: @@ -438,7 +437,7 @@ func setDefaultFloat(field reflect.Value, f float64) error { // GenerateSampleConfig generates a sample configuration for a config struct // The format parameter can be "yaml", "json", or "toml" -func GenerateSampleConfig(cfg interface{}, format string) ([]byte, error) { +func GenerateSampleConfig(cfg any, format string) ([]byte, error) { if cfg == nil { return nil, ErrConfigNil } @@ -476,10 +475,10 @@ func GenerateSampleConfig(cfg interface{}, format string) ([]byte, error) { } // mapStructFieldsForJSON creates a map with proper JSON field names based on struct tags -func mapStructFieldsForJSON(cfg interface{}) map[string]interface{} { - result := make(map[string]interface{}) +func mapStructFieldsForJSON(cfg any) map[string]any { + result := make(map[string]any) v := reflect.ValueOf(cfg) - if v.Kind() == reflect.Ptr { + if v.Kind() == reflect.Pointer { v = v.Elem() } t := v.Type() @@ -508,7 +507,7 @@ func mapStructFieldsForJSON(cfg interface{}) map[string]interface{} { switch field.Kind() { //nolint:exhaustive // only handling specific cases we care about case reflect.Struct: result[fieldName] = mapStructFieldsForJSON(field.Interface()) - case reflect.Ptr: + case reflect.Pointer: if !field.IsNil() && field.Elem().Kind() == reflect.Struct { result[fieldName] = mapStructFieldsForJSON(field.Interface()) } else { @@ -524,7 +523,7 @@ func mapStructFieldsForJSON(cfg interface{}) map[string]interface{} { } // SaveSampleConfig generates and saves a sample configuration file -func SaveSampleConfig(cfg interface{}, format, filePath string) error { +func SaveSampleConfig(cfg any, format, filePath string) error { data, err := GenerateSampleConfig(cfg, format) if err != nil { return err @@ -540,7 +539,7 @@ func SaveSampleConfig(cfg interface{}, format, filePath string) error { // 1. Processes default values // 2. Validates required fields // 3. If the config implements ConfigValidator, calls its Validate method -func ValidateConfig(cfg interface{}) error { +func ValidateConfig(cfg any) error { if cfg == nil { return ErrConfigNil } diff --git a/config_validation_test.go b/config_validation_test.go index b9ffce19..3dde20bd 100644 --- a/config_validation_test.go +++ b/config_validation_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -50,8 +50,8 @@ func (c *ValidationTestConfig) Validate() error { func TestProcessConfigDefaults(t *testing.T) { tests := []struct { name string - cfg interface{} - expected interface{} + cfg any + expected any wantErr bool }{ { @@ -116,7 +116,7 @@ func TestProcessConfigDefaults(t *testing.T) { func TestValidateConfigRequired(t *testing.T) { tests := []struct { name string - cfg interface{} + cfg any wantErr bool errorMsg string }{ @@ -182,7 +182,7 @@ func TestValidateConfigRequired(t *testing.T) { func TestValidateConfig(t *testing.T) { tests := []struct { name string - cfg interface{} + cfg any wantErr bool }{ { @@ -242,7 +242,7 @@ func TestGenerateSampleConfig(t *testing.T) { // Test JSON generation jsonData, err := GenerateSampleConfig(cfg, "json") require.NoError(t, err) - var jsonCfg map[string]interface{} + var jsonCfg map[string]any err = json.Unmarshal(jsonData, &jsonCfg) require.NoError(t, err) assert.Equal(t, "Default Name", jsonCfg["name"]) diff --git a/configuration_base_bdd_test.go b/configuration_base_bdd_test.go index e393b33c..0a62cf08 100644 --- a/configuration_base_bdd_test.go +++ b/configuration_base_bdd_test.go @@ -44,7 +44,7 @@ type ConfigBDDTestContext struct { jsonFile string environmentVars map[string]string originalEnvVars map[string]string - configData interface{} + configData any isValid bool validationErrors []string fieldTracker *TestFieldTracker diff --git a/contract_verifier.go b/contract_verifier.go new file mode 100644 index 00000000..918c0635 --- /dev/null +++ b/contract_verifier.go @@ -0,0 +1,225 @@ +package modular + +import ( + "context" + "fmt" + "sync" + "time" +) + +// ContractViolation describes a single violation found during contract verification. +type ContractViolation struct { + Contract string // "reload" or "health" + Rule string // e.g., "must-return-positive-timeout" + Description string + Severity string // "error" or "warning" +} + +// ContractVerifier verifies that implementations of Reloadable and HealthProvider +// satisfy their behavioral contracts beyond what the type system enforces. +type ContractVerifier interface { + VerifyReloadContract(module Reloadable) []ContractViolation + VerifyHealthContract(provider HealthProvider) []ContractViolation +} + +// StandardContractVerifier is the default implementation of ContractVerifier. +type StandardContractVerifier struct{} + +// NewStandardContractVerifier creates a new StandardContractVerifier. +func NewStandardContractVerifier() *StandardContractVerifier { + return &StandardContractVerifier{} +} + +// VerifyReloadContract checks that a Reloadable module satisfies its behavioral contract: +// 1. ReloadTimeout() returns a positive duration +// 2. CanReload() is safe to call concurrently (no panics) +// 3. Reload() with empty changes is idempotent +// 4. Reload() respects context cancellation +func (v *StandardContractVerifier) VerifyReloadContract(module Reloadable) []ContractViolation { + var violations []ContractViolation + + // 1. ReloadTimeout must return a positive duration. + if timeout := module.ReloadTimeout(); timeout <= 0 { + violations = append(violations, ContractViolation{ + Contract: "reload", + Rule: "must-return-positive-timeout", + Description: fmt.Sprintf("ReloadTimeout() returned %v, must be > 0", timeout), + Severity: "error", + }) + } + + // 2. CanReload must be safe to call concurrently (no panics). + if panicked := v.checkCanReloadConcurrency(module); panicked { + violations = append(violations, ContractViolation{ + Contract: "reload", + Rule: "can-reload-must-not-panic", + Description: "CanReload() panicked during concurrent invocation", + Severity: "warning", + }) + } + + // 3. Reload with empty changes should be idempotent. + if err := v.checkReloadIdempotent(module); err != nil { + violations = append(violations, ContractViolation{ + Contract: "reload", + Rule: "empty-reload-must-be-idempotent", + Description: fmt.Sprintf("Reload() with empty changes failed: %v", err), + Severity: "warning", + }) + } + + // 4. Reload must respect context cancellation. + if !v.checkReloadRespectsCancel(module) { + violations = append(violations, ContractViolation{ + Contract: "reload", + Rule: "must-respect-context-cancellation", + Description: "Reload() with cancelled context did not return an error", + Severity: "warning", + }) + } + + return violations +} + +// checkCanReloadConcurrency calls CanReload 100 times concurrently and reports +// whether any invocation panicked. +func (v *StandardContractVerifier) checkCanReloadConcurrency(module Reloadable) bool { + var ( + wg sync.WaitGroup + panicked int32 + mu sync.Mutex + ) + + for range 100 { + wg.Go(func() { + defer func() { + if r := recover(); r != nil { + mu.Lock() + panicked = 1 + mu.Unlock() + } + }() + module.CanReload() + }) + } + wg.Wait() + return panicked != 0 +} + +// checkReloadIdempotent calls Reload with empty changes twice and returns an error +// if either call fails or hangs beyond the timeout. Each call is guarded by a +// goroutine so a misbehaving module cannot block the verifier indefinitely. +func (v *StandardContractVerifier) checkReloadIdempotent(module Reloadable) error { + for i, label := range []string{"first", "second"} { + _ = i + if err := v.runReloadWithGuard(module, label); err != nil { + return err + } + } + return nil +} + +// runReloadWithGuard runs module.Reload in a goroutine and returns an error if +// it fails or exceeds the 5-second timeout. +func (v *StandardContractVerifier) runReloadWithGuard(module Reloadable, label string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + type result struct{ err error } + ch := make(chan result, 1) + go func() { + ch <- result{err: module.Reload(ctx, nil)} + }() + + select { + case r := <-ch: + if r.err != nil { + return fmt.Errorf("%s call: %w", label, r.err) + } + return nil + case <-ctx.Done(): + return fmt.Errorf("%s call: %w", label, ErrReloadTimeout) + } +} + +// checkReloadRespectsCancel calls Reload with an already-cancelled context and +// returns true if Reload returned an error (i.e., it respected the cancellation). +func (v *StandardContractVerifier) checkReloadRespectsCancel(module Reloadable) bool { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + err := module.Reload(ctx, nil) + return err != nil +} + +// VerifyHealthContract checks that a HealthProvider satisfies its behavioral contract: +// 1. HealthCheck returns within 5 seconds +// 2. Reports have non-empty Module field +// 3. Reports have non-empty Component field +// 4. HealthCheck with cancelled context returns an error +func (v *StandardContractVerifier) VerifyHealthContract(provider HealthProvider) []ContractViolation { + var violations []ContractViolation + + // 1 + 2 + 3: Check that HealthCheck returns in time and reports have required fields. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + type result struct { + reports []HealthReport + err error + } + ch := make(chan result, 1) + go func() { + reports, err := provider.HealthCheck(ctx) + ch <- result{reports, err} + }() + + select { + case <-ctx.Done(): + violations = append(violations, ContractViolation{ + Contract: "health", + Rule: "must-return-within-timeout", + Description: "HealthCheck() did not return within 5 seconds", + Severity: "error", + }) + // Can't check fields if we timed out. + return violations + case res := <-ch: + if res.err == nil { + for _, report := range res.reports { + if report.Module == "" { + violations = append(violations, ContractViolation{ + Contract: "health", + Rule: "must-have-module-field", + Description: "HealthReport has empty Module field", + Severity: "error", + }) + } + if report.Component == "" { + violations = append(violations, ContractViolation{ + Contract: "health", + Rule: "must-have-component-field", + Description: "HealthReport has empty Component field", + Severity: "error", + }) + } + } + } + } + + // 4. HealthCheck with cancelled context should return an error. + cancelCtx, cancelFn := context.WithCancel(context.Background()) + cancelFn() + + _, err := provider.HealthCheck(cancelCtx) + if err == nil { + violations = append(violations, ContractViolation{ + Contract: "health", + Rule: "must-respect-context-cancellation", + Description: "HealthCheck() with cancelled context did not return an error", + Severity: "warning", + }) + } + + return violations +} diff --git a/contract_verifier_test.go b/contract_verifier_test.go new file mode 100644 index 00000000..a4237365 --- /dev/null +++ b/contract_verifier_test.go @@ -0,0 +1,164 @@ +package modular + +import ( + "context" + "testing" + "time" +) + +// --- Mock Reloadable modules for contract tests --- + +// wellBehavedReloadable satisfies all reload contract rules. +type wellBehavedReloadable struct{} + +func (w *wellBehavedReloadable) Reload(ctx context.Context, _ []ConfigChange) error { + if err := ctx.Err(); err != nil { + return err + } + return nil +} +func (w *wellBehavedReloadable) CanReload() bool { return true } +func (w *wellBehavedReloadable) ReloadTimeout() time.Duration { return 5 * time.Second } + +// zeroTimeoutReloadable returns a zero timeout. +type zeroTimeoutReloadable struct{ wellBehavedReloadable } + +func (z *zeroTimeoutReloadable) ReloadTimeout() time.Duration { return 0 } + +// panickyReloadable panics when CanReload is called. +type panickyReloadable struct{ wellBehavedReloadable } + +func (p *panickyReloadable) CanReload() bool { panic("boom") } + +// --- Mock HealthProviders for contract tests --- + +// wellBehavedHealthProvider returns a proper report and respects cancellation. +type wellBehavedHealthProvider struct{} + +func (w *wellBehavedHealthProvider) HealthCheck(ctx context.Context) ([]HealthReport, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return []HealthReport{ + { + Module: "test-module", + Component: "test-component", + Status: StatusHealthy, + Message: "ok", + CheckedAt: time.Now(), + }, + }, nil +} + +// emptyModuleHealthProvider returns a report with empty Module field. +type emptyModuleHealthProvider struct{} + +func (e *emptyModuleHealthProvider) HealthCheck(ctx context.Context) ([]HealthReport, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return []HealthReport{ + { + Module: "", + Component: "comp", + Status: StatusHealthy, + CheckedAt: time.Now(), + }, + }, nil +} + +// cancelIgnoringHealthProvider ignores context cancellation. +type cancelIgnoringHealthProvider struct{} + +func (c *cancelIgnoringHealthProvider) HealthCheck(_ context.Context) ([]HealthReport, error) { + return []HealthReport{ + { + Module: "mod", + Component: "comp", + Status: StatusHealthy, + CheckedAt: time.Now(), + }, + }, nil +} + +// --- Tests --- + +func TestContractVerifier_ReloadWellBehaved(t *testing.T) { + verifier := NewStandardContractVerifier() + violations := verifier.VerifyReloadContract(&wellBehavedReloadable{}) + if len(violations) != 0 { + t.Fatalf("expected 0 violations for well-behaved reloadable, got %d: %+v", len(violations), violations) + } +} + +func TestContractVerifier_ReloadZeroTimeout(t *testing.T) { + verifier := NewStandardContractVerifier() + violations := verifier.VerifyReloadContract(&zeroTimeoutReloadable{}) + + found := false + for _, v := range violations { + if v.Rule == "must-return-positive-timeout" && v.Severity == "error" { + found = true + break + } + } + if !found { + t.Fatalf("expected violation for zero timeout, got: %+v", violations) + } +} + +func TestContractVerifier_ReloadPanicsOnCanReload(t *testing.T) { + verifier := NewStandardContractVerifier() + violations := verifier.VerifyReloadContract(&panickyReloadable{}) + + found := false + for _, v := range violations { + if v.Rule == "can-reload-must-not-panic" && v.Severity == "warning" { + found = true + break + } + } + if !found { + t.Fatalf("expected violation for panicky CanReload, got: %+v", violations) + } +} + +func TestContractVerifier_HealthWellBehaved(t *testing.T) { + verifier := NewStandardContractVerifier() + violations := verifier.VerifyHealthContract(&wellBehavedHealthProvider{}) + if len(violations) != 0 { + t.Fatalf("expected 0 violations for well-behaved health provider, got %d: %+v", len(violations), violations) + } +} + +func TestContractVerifier_HealthEmptyModule(t *testing.T) { + verifier := NewStandardContractVerifier() + violations := verifier.VerifyHealthContract(&emptyModuleHealthProvider{}) + + found := false + for _, v := range violations { + if v.Rule == "must-have-module-field" && v.Severity == "error" { + found = true + break + } + } + if !found { + t.Fatalf("expected violation for empty Module field, got: %+v", violations) + } +} + +func TestContractVerifier_HealthIgnoresCancellation(t *testing.T) { + verifier := NewStandardContractVerifier() + violations := verifier.VerifyHealthContract(&cancelIgnoringHealthProvider{}) + + found := false + for _, v := range violations { + if v.Rule == "must-respect-context-cancellation" && v.Severity == "warning" { + found = true + break + } + } + if !found { + t.Fatalf("expected violation for ignoring cancellation, got: %+v", violations) + } +} diff --git a/cycle_detection_modules_bdd_test.go b/cycle_detection_modules_bdd_test.go index e6a822b7..d55b325f 100644 --- a/cycle_detection_modules_bdd_test.go +++ b/cycle_detection_modules_bdd_test.go @@ -26,7 +26,7 @@ func (m *CycleModuleA) RequiresServices() []ServiceDependency { Name: "serviceB", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceB)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceB](), }} } @@ -50,7 +50,7 @@ func (m *CycleModuleB) RequiresServices() []ServiceDependency { Name: "serviceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceA)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceA](), }} } @@ -82,7 +82,7 @@ func (m *LinearModuleB) RequiresServices() []ServiceDependency { Name: "linearServiceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceA)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceA](), }} } @@ -106,7 +106,7 @@ func (m *SelfDependentModule) RequiresServices() []ServiceDependency { Name: "selfService", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceA)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceA](), }} } @@ -153,7 +153,7 @@ func (m *MixedDependencyModuleB) RequiresServices() []ServiceDependency { Name: "mixedServiceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceA)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceA](), }} } @@ -177,7 +177,7 @@ func (m *ComplexCycleModuleA) RequiresServices() []ServiceDependency { Name: "complexServiceB", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceB)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceB](), }} } @@ -201,7 +201,7 @@ func (m *ComplexCycleModuleB) RequiresServices() []ServiceDependency { Name: "complexServiceC", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceC)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceC](), }} } @@ -225,7 +225,7 @@ func (m *ComplexCycleModuleC) RequiresServices() []ServiceDependency { Name: "complexServiceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceA)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceA](), }} } @@ -249,7 +249,7 @@ func (m *DisambiguationModuleA) RequiresServices() []ServiceDependency { Name: "disambiguationServiceB", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*AnotherEnhancedTestInterface)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[AnotherEnhancedTestInterface](), }} } @@ -273,6 +273,6 @@ func (m *DisambiguationModuleB) RequiresServices() []ServiceDependency { Name: "disambiguationServiceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*EnhancedTestInterface)(nil)).Elem(), // Note: different interface + SatisfiesInterface: reflect.TypeFor[EnhancedTestInterface](), // Note: different interface }} } diff --git a/cycle_detection_test.go b/cycle_detection_test.go index 432d6d6e..7ce11033 100644 --- a/cycle_detection_test.go +++ b/cycle_detection_test.go @@ -44,7 +44,7 @@ func (m *CycleTestModuleA) RequiresServices() []ServiceDependency { Name: "testServiceB", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterface)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterface](), }, } } @@ -81,7 +81,7 @@ func (m *CycleTestModuleB) RequiresServices() []ServiceDependency { Name: "testServiceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterface)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterface](), }, } } diff --git a/database_interface_matching_test.go b/database_interface_matching_test.go index a0cc5eca..f2474f5c 100644 --- a/database_interface_matching_test.go +++ b/database_interface_matching_test.go @@ -12,9 +12,9 @@ import ( // DatabaseExecutor matches the user's interface from the problem description type DatabaseExecutor interface { - ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) - QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) - QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) } @@ -25,15 +25,15 @@ var _ DatabaseExecutor = (*sql.DB)(nil) // mockDatabaseExecutor is a mock implementation for testing type mockDatabaseExecutor struct{} -func (m *mockDatabaseExecutor) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { +func (m *mockDatabaseExecutor) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { return &mockResult{}, nil } -func (m *mockDatabaseExecutor) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { +func (m *mockDatabaseExecutor) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { return nil, nil } -func (m *mockDatabaseExecutor) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row { +func (m *mockDatabaseExecutor) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { return &sql.Row{} } @@ -80,19 +80,19 @@ func TestInterfaceMatchingCore(t *testing.T) { mockService := &mockDatabaseServiceImpl{executor: mockExecutor} // Test 1: Check if mockDatabaseExecutor implements DatabaseExecutor (it should) - expectedType := reflect.TypeOf((*DatabaseExecutor)(nil)).Elem() - mockExecutorType := reflect.TypeOf((*mockDatabaseExecutor)(nil)) + expectedType := reflect.TypeFor[DatabaseExecutor]() + mockExecutorType := reflect.TypeFor[*mockDatabaseExecutor]() assert.True(t, mockExecutorType.Implements(expectedType), "mockDatabaseExecutor should implement DatabaseExecutor interface") // Test 2: Check if mockDatabaseServiceImpl implements DatabaseExecutor (it should NOT) - mockServiceType := reflect.TypeOf((*mockDatabaseServiceImpl)(nil)) + mockServiceType := reflect.TypeFor[*mockDatabaseServiceImpl]() assert.False(t, mockServiceType.Implements(expectedType), "mockDatabaseServiceImpl should NOT implement DatabaseExecutor interface") // Test 3: Check if mockDatabaseServiceImpl implements MockDatabaseService (it should) - mockDBServiceType := reflect.TypeOf((*MockDatabaseService)(nil)).Elem() + mockDBServiceType := reflect.TypeFor[MockDatabaseService]() assert.True(t, mockServiceType.Implements(mockDBServiceType), "mockDatabaseServiceImpl should implement MockDatabaseService interface") diff --git a/debug_module_interfaces.go b/debug_module_interfaces.go index 8d6d53c7..ea12996b 100644 --- a/debug_module_interfaces.go +++ b/debug_module_interfaces.go @@ -23,7 +23,7 @@ func DebugModuleInterfaces(app Application, moduleName string) { fmt.Printf(" Memory address: %p\n", module) // Check all the interfaces - interfaces := map[string]interface{}{ + interfaces := map[string]any{ "Module": (*Module)(nil), "Configurable": (*Configurable)(nil), "DependencyAware": (*DependencyAware)(nil), diff --git a/debug_module_test.go b/debug_module_test.go index d15cae63..35a21705 100644 --- a/debug_module_test.go +++ b/debug_module_test.go @@ -22,7 +22,7 @@ func TestModuleReplacementLosesStartable(t *testing.T) { originalModule := &ProblematicModule{name: "test-module"} // Verify the original module implements Startable - _, implementsStartable := interface{}(originalModule).(Startable) + _, implementsStartable := any(originalModule).(Startable) require.True(t, implementsStartable, "Original module should implement Startable") // Register the module @@ -81,7 +81,7 @@ func TestProperModuleConstructorPattern(t *testing.T) { originalModule := &CorrectModule{name: "correct-module"} // Verify the original module implements Startable - _, implementsStartable := interface{}(originalModule).(Startable) + _, implementsStartable := any(originalModule).(Startable) require.True(t, implementsStartable, "Original module should implement Startable") // Register the module diff --git a/decorator_config.go b/decorator_config.go index f0f9609e..a8522630 100644 --- a/decorator_config.go +++ b/decorator_config.go @@ -25,7 +25,7 @@ type instanceAwareConfigProvider struct { } // GetConfig returns the base configuration -func (p *instanceAwareConfigProvider) GetConfig() interface{} { +func (p *instanceAwareConfigProvider) GetConfig() any { return p.base.GetConfig() } @@ -54,7 +54,7 @@ type tenantAwareConfigProvider struct { } // GetConfig returns the base configuration -func (p *tenantAwareConfigProvider) GetConfig() interface{} { +func (p *tenantAwareConfigProvider) GetConfig() any { return p.base.GetConfig() } @@ -62,7 +62,7 @@ func (p *tenantAwareConfigProvider) GetConfig() interface{} { var errNoTenantLoaderConfigured = errors.New("no tenant loader configured") // GetTenantConfig retrieves configuration for a specific tenant -func (p *tenantAwareConfigProvider) GetTenantConfig(tenantID TenantID) (interface{}, error) { +func (p *tenantAwareConfigProvider) GetTenantConfig(tenantID TenantID) (any, error) { if p.loader == nil { return nil, errNoTenantLoaderConfigured } diff --git a/decorator_observable.go b/decorator_observable.go index fb8d3759..43c11480 100644 --- a/decorator_observable.go +++ b/decorator_observable.go @@ -45,7 +45,7 @@ func (d *ObservableDecorator) RemoveObserver(observer ObserverFunc) { } // emitEvent emits a CloudEvent to all registered observers -func (d *ObservableDecorator) emitEvent(ctx context.Context, eventType string, data interface{}, metadata map[string]interface{}) { +func (d *ObservableDecorator) emitEvent(ctx context.Context, eventType string, data any, metadata map[string]any) { event := NewCloudEvent(eventType, "application", data, metadata) d.observerMutex.RLock() @@ -55,7 +55,6 @@ func (d *ObservableDecorator) emitEvent(ctx context.Context, eventType string, d // Notify observers in goroutines to avoid blocking for _, observer := range observers { - observer := observer // capture for goroutine go func() { defer func() { if r := recover(); r != nil { @@ -77,7 +76,7 @@ func (d *ObservableDecorator) Init() error { ctx := context.Background() // Emit before init event - d.emitEvent(ctx, "com.modular.application.before.init", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.before.init", nil, map[string]any{ "phase": "before_init", "timestamp": time.Now().Format(time.RFC3339), }) @@ -86,9 +85,9 @@ func (d *ObservableDecorator) Init() error { if err != nil { // Emit init failed event - d.emitEvent(ctx, "com.modular.application.init.failed", map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.init.failed", map[string]any{ "error": err.Error(), - }, map[string]interface{}{ + }, map[string]any{ "phase": "init_failed", "timestamp": time.Now().Format(time.RFC3339), }) @@ -96,7 +95,7 @@ func (d *ObservableDecorator) Init() error { } // Emit after init event - d.emitEvent(ctx, "com.modular.application.after.init", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.after.init", nil, map[string]any{ "phase": "after_init", "timestamp": time.Now().Format(time.RFC3339), }) @@ -109,7 +108,7 @@ func (d *ObservableDecorator) Start() error { ctx := context.Background() // Emit before start event - d.emitEvent(ctx, "com.modular.application.before.start", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.before.start", nil, map[string]any{ "phase": "before_start", "timestamp": time.Now().Format(time.RFC3339), }) @@ -118,9 +117,9 @@ func (d *ObservableDecorator) Start() error { if err != nil { // Emit start failed event - d.emitEvent(ctx, "com.modular.application.start.failed", map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.start.failed", map[string]any{ "error": err.Error(), - }, map[string]interface{}{ + }, map[string]any{ "phase": "start_failed", "timestamp": time.Now().Format(time.RFC3339), }) @@ -128,7 +127,7 @@ func (d *ObservableDecorator) Start() error { } // Emit after start event - d.emitEvent(ctx, "com.modular.application.after.start", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.after.start", nil, map[string]any{ "phase": "after_start", "timestamp": time.Now().Format(time.RFC3339), }) @@ -141,7 +140,7 @@ func (d *ObservableDecorator) Stop() error { ctx := context.Background() // Emit before stop event - d.emitEvent(ctx, "com.modular.application.before.stop", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.before.stop", nil, map[string]any{ "phase": "before_stop", "timestamp": time.Now().Format(time.RFC3339), }) @@ -150,9 +149,9 @@ func (d *ObservableDecorator) Stop() error { if err != nil { // Emit stop failed event - d.emitEvent(ctx, "com.modular.application.stop.failed", map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.stop.failed", map[string]any{ "error": err.Error(), - }, map[string]interface{}{ + }, map[string]any{ "phase": "stop_failed", "timestamp": time.Now().Format(time.RFC3339), }) @@ -160,7 +159,7 @@ func (d *ObservableDecorator) Stop() error { } // Emit after stop event - d.emitEvent(ctx, "com.modular.application.after.stop", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.after.stop", nil, map[string]any{ "phase": "after_stop", "timestamp": time.Now().Format(time.RFC3339), }) diff --git a/docs/plans/2026-03-09-modular-v2-enhancements-design.md b/docs/plans/2026-03-09-modular-v2-enhancements-design.md new file mode 100644 index 00000000..f391b4c9 --- /dev/null +++ b/docs/plans/2026-03-09-modular-v2-enhancements-design.md @@ -0,0 +1,236 @@ +# Modular v2 Enhancements Design + +**Goal:** Address 12 gaps identified in the Modular framework audit, making it a more complete foundation for the Workflow engine and other consumers. + +**Delivery:** Single PR on the `feat/reimplementation` branch. + +**Consumer:** GoCodeAlone/workflow engine (primary), other Go services using Modular. + +--- + +## Section 1: Core Lifecycle + +### 1.1 Config-Driven Dependency Hints + +**Gap:** Modules can declare dependencies via `DependencyAware` interface, but there's no way to declare them from the builder/config level without modifying module code. + +**Design:** `WithModuleDependency(from, to string)` builder option injects edges into the dependency graph before resolution. These hints feed into the existing topological sort alongside `DependencyAware` edges. + +```go +app := modular.NewApplicationBuilder(). + WithModuleDependency("api-server", "database"). + WithModuleDependency("api-server", "cache"). + Build() +``` + +Implementation: Store hints in `[]DependencyEdge` on the builder, merge into the graph in `resolveDependencies()` before DFS. + +### 1.2 Drainable Interface (Shutdown Drain Phases) + +**Gap:** `Stoppable` has a single `Stop()` method. No way to drain in-flight work before hard stop. + +**Design:** New `Drainable` interface with `PreStop(ctx)` called before `Stop()`: + +```go +type Drainable interface { + PreStop(ctx context.Context) error +} +``` + +Shutdown sequence: `PreStop` all drainable modules (reverse dependency order) → `Stop` all stoppable modules (reverse dependency order). `PreStop` context has a configurable timeout via `WithDrainTimeout(d)`. + +### 1.3 Application Phase Tracking + +**Gap:** No way to query what lifecycle phase the application is in. + +**Design:** `Phase()` method on Application returning an enum: + +```go +type AppPhase int +const ( + PhaseCreated AppPhase = iota + PhaseInitializing + PhaseStarting + PhaseRunning + PhaseDraining + PhaseStopping + PhaseStopped +) +``` + +Phase transitions emit CloudEvents (`EventTypeAppPhaseChanged`) if a Subject is configured. + +### 1.4 Parallel Init at Same Topological Depth + +**Gap:** Modules at the same depth in the dependency graph are initialized sequentially. + +**Design:** `WithParallelInit()` builder option. When enabled, modules at the same topological depth are initialized concurrently via `errgroup`. Modules at different depths remain sequential (respecting dependency order). + +Disabled by default for backward compatibility. Errors from any goroutine cancel the group and return the first error. + +--- + +## Section 2: Services & Plugins + +### 2.1 Type-Safe Service Helpers + +**Gap:** `RegisterService`/`GetService` use `interface{}`, requiring type assertions at every call site. + +**Design:** Package-level generic helper functions (not methods, since Go interfaces can't have type parameters): + +```go +func RegisterTypedService[T any](registry ServiceRegistry, name string, svc T) error +func GetTypedService[T any](registry ServiceRegistry, name string) (T, error) +``` + +These wrap the existing `RegisterService`/`GetService` with compile-time type safety. `GetTypedService` returns a typed zero value + error on type mismatch. + +### 2.2 Service Readiness Events + +**Gap:** No notification when a service becomes available, making lazy/async resolution brittle. + +**Design:** `EventTypeServiceRegistered` CloudEvent emitted by `EnhancedServiceRegistry.RegisterService()`. Plus `OnServiceReady(name, callback)` method that fires the callback immediately if already registered, or defers until registration. + +```go +registry.OnServiceReady("database", func(svc interface{}) { + db := svc.(*sql.DB) + // use db +}) +``` + +### 2.3 Plugin Interface + +**Gap:** No standard way to bundle modules, services, and hooks as a distributable unit. + +**Design:** Three interfaces with progressive capability: + +```go +type Plugin interface { + Name() string + Modules() []Module +} + +type PluginWithHooks interface { + Plugin + InitHooks() []func(Application) error +} + +type PluginWithServices interface { + Plugin + Services() []ServiceDefinition +} + +type ServiceDefinition struct { + Name string + Service interface{} +} +``` + +Builder gains `WithPlugins(...Plugin)`: registers all modules, runs hooks during init, registers services before module init. + +--- + +## Section 3: Configuration & Reload + +### 3.1 ReloadOrchestrator Integration + +**Gap:** `ReloadOrchestrator` exists but isn't wired into the Application lifecycle. + +**Design:** `WithDynamicReload()` builder option: +- Creates `ReloadOrchestrator` during `Build()` +- Auto-registers all `Reloadable` modules after init +- Calls `Start()` during app start, `Stop()` during app stop +- Exposes `Application.RequestReload(ctx, trigger, diff)` for consumers + +### 3.2 Config File Watcher + +**Gap:** No built-in file watching for configuration changes. + +**Design:** New `modules/configwatcher` package providing a module that watches config files: + +```go +watcher := configwatcher.New( + configwatcher.WithPaths("config/app.yaml", "config/overrides.yaml"), + configwatcher.WithDebounce(500 * time.Millisecond), + configwatcher.WithDiffFunc(myDiffFunc), +) +``` + +Uses `fsnotify` (single new dependency). On change: debounce → compute diff → call `Application.RequestReload()`. Implements `Startable`/`Stoppable` for lifecycle management. + +### 3.3 Secret Resolution Hooks + +**Gap:** Config values like `${vault:secret/db-password}` have no standard expansion mechanism. + +**Design:** `SecretResolver` interface + utility function: + +```go +type SecretResolver interface { + ResolveSecret(ctx context.Context, ref string) (string, error) + CanResolve(ref string) bool +} + +func ExpandSecrets(ctx context.Context, config map[string]any, resolvers ...SecretResolver) error +``` + +`ExpandSecrets` walks the config map, finds string values matching `${prefix:path}`, dispatches to the first resolver where `CanResolve` returns true, and replaces in-place. Called by consumers before feeding config to modules. + +--- + +## Section 4: Observability + +### 4.1 Slog Adapter + +**Gap:** Framework uses custom `Logger` interface. Go's `slog` is the standard. + +**Design:** Keep `Logger` interface unchanged. Add `SlogAdapter` implementing `Logger` by wrapping `*slog.Logger`: + +```go +type SlogAdapter struct { + logger *slog.Logger +} + +func NewSlogAdapter(l *slog.Logger) *SlogAdapter +func (a *SlogAdapter) With(args ...any) *SlogAdapter +func (a *SlogAdapter) WithGroup(name string) *SlogAdapter +``` + +`With()`/`WithGroup()` return `*SlogAdapter` (not `Logger`) for chaining structured context. Base `Logger` interface methods (`Info`, `Error`, `Warn`, `Debug`) delegate to slog equivalents. + +### 4.2 Module Metrics Hooks + +**Gap:** No standard way for modules to expose operational metrics. + +**Design:** Optional `MetricsProvider` interface: + +```go +type ModuleMetrics struct { + Name string + Values map[string]float64 +} + +type MetricsProvider interface { + CollectMetrics(ctx context.Context) ModuleMetrics +} +``` + +`Application.CollectAllMetrics(ctx) []ModuleMetrics` iterates modules implementing `MetricsProvider`. No OTEL/Prometheus dependency — returns raw values for consumers to map to their telemetry system. + +--- + +## Gap Matrix Summary + +| # | Gap | Section | Key Types | +|---|-----|---------|-----------| +| 1 | Config-driven dependency hints | 1.1 | `WithModuleDependency` | +| 2 | Shutdown drain phases | 1.2 | `Drainable`, `PreStop` | +| 3 | Application phase tracking | 1.3 | `AppPhase`, `Phase()` | +| 4 | Parallel init | 1.4 | `WithParallelInit` | +| 5 | Type-safe services | 2.1 | `RegisterTypedService[T]` | +| 6 | Service readiness events | 2.2 | `OnServiceReady` | +| 7 | Plugin interface | 2.3 | `Plugin`, `WithPlugins` | +| 8 | Reload orchestrator integration | 3.1 | `WithDynamicReload` | +| 9 | Config file watcher | 3.2 | `configwatcher` module | +| 10 | Secret resolution hooks | 3.3 | `SecretResolver` | +| 11 | Slog adapter | 4.1 | `SlogAdapter` | +| 12 | Module metrics hooks | 4.2 | `MetricsProvider` | diff --git a/docs/plans/2026-03-09-modular-v2-enhancements.md b/docs/plans/2026-03-09-modular-v2-enhancements.md new file mode 100644 index 00000000..eeeb8294 --- /dev/null +++ b/docs/plans/2026-03-09-modular-v2-enhancements.md @@ -0,0 +1,2254 @@ +# Modular v2 Enhancements Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement 12 framework enhancements to the GoCodeAlone/modular framework covering lifecycle, services, plugins, configuration, reload, and observability. + +**Architecture:** All changes are in the root `modular` package except the config file watcher (new `modules/configwatcher` subpackage). The existing `Application` interface, `StdApplication` struct, and `ApplicationBuilder` are extended. New interfaces (`Drainable`, `Plugin`, `MetricsProvider`, `SecretResolver`) follow the existing optional-interface pattern. Generic service helpers use Go 1.26 type parameters. + +**Tech Stack:** Go 1.26, CloudEvents SDK, fsnotify (new dependency for configwatcher) + +--- + +### Task 1: Config-Driven Dependency Hints (`WithModuleDependency`) + +**Files:** +- Modify: `builder.go` — add `dependencyHints` field, `WithModuleDependency` option +- Modify: `application.go` — merge hints into `resolveDependencies()` +- Create: `builder_dependency_test.go` — tests +- Modify: `errors.go` — add sentinel if needed + +**Step 1: Write the failing test** + +Create `builder_dependency_test.go`: + +```go +package modular + +import ( + "context" + "testing" +) + +// testDepModule is a minimal module for dependency hint testing. +type testDepModule struct { + name string + initSeq *[]string +} + +func (m *testDepModule) Name() string { return m.name } +func (m *testDepModule) Init(app Application) error { + *m.initSeq = append(*m.initSeq, m.name) + return nil +} + +func TestWithModuleDependency_OrdersModulesCorrectly(t *testing.T) { + seq := make([]string, 0) + modA := &testDepModule{name: "alpha", initSeq: &seq} + modB := &testDepModule{name: "beta", initSeq: &seq} + + // Without dependency hints, alpha inits before beta (alphabetical DFS). + // With WithModuleDependency("alpha", "beta"), beta must init first. + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + WithModuleDependency("alpha", "beta"), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + if len(seq) != 2 || seq[0] != "beta" || seq[1] != "alpha" { + t.Errorf("expected init order [beta, alpha], got %v", seq) + } +} + +func TestWithModuleDependency_DetectsCycle(t *testing.T) { + modA := &testDepModule{name: "alpha", initSeq: new([]string)} + modB := &testDepModule{name: "beta", initSeq: new([]string)} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + WithModuleDependency("alpha", "beta"), + WithModuleDependency("beta", "alpha"), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + err = app.Init() + if err == nil { + t.Fatal("expected circular dependency error") + } + if !IsErrCircularDependency(err) { + t.Errorf("expected ErrCircularDependency, got: %v", err) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestWithModuleDependency -count=1 -v` +Expected: FAIL — `WithModuleDependency` undefined + +**Step 3: Implement** + +In `builder.go`, add to `ApplicationBuilder`: +```go +dependencyHints []DependencyEdge +``` + +Add option function: +```go +// WithModuleDependency declares that module `from` depends on module `to`, +// injecting an edge into the dependency graph before resolution. +func WithModuleDependency(from, to string) Option { + return func(b *ApplicationBuilder) error { + b.dependencyHints = append(b.dependencyHints, DependencyEdge{ + From: from, + To: to, + Type: EdgeTypeModule, + }) + return nil + } +} +``` + +In `Build()`, after creating the app and before registering modules, store hints on the StdApplication. Add a new field to `StdApplication`: +```go +dependencyHints []DependencyEdge +``` + +In `Build()`, after `app` is created, set hints: +```go +if len(b.dependencyHints) > 0 { + if stdApp, ok := app.(*StdApplication); ok { + stdApp.dependencyHints = b.dependencyHints + } else if obsApp, ok := app.(*ObservableApplication); ok { + obsApp.dependencyHints = b.dependencyHints + } +} +``` + +In `resolveDependencies()` in `application.go`, after building the graph from `DependencyAware` modules (around line 1104), add: +```go +// Merge config-driven dependency hints +for _, hint := range app.dependencyHints { + if graph[hint.From] == nil { + graph[hint.From] = nil + } + graph[hint.From] = append(graph[hint.From], hint.To) + dependencyEdges = append(dependencyEdges, hint) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestWithModuleDependency -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add builder.go application.go builder_dependency_test.go +git commit -m "feat: add WithModuleDependency for config-driven dependency hints" +``` + +--- + +### Task 2: Drainable Interface (Shutdown Drain Phases) + +**Files:** +- Create: `drainable.go` — interface + drain timeout option +- Modify: `application.go` — call PreStop before Stop in `Stop()` +- Modify: `builder.go` — add `WithDrainTimeout` option +- Create: `drainable_test.go` — tests + +**Step 1: Write the failing test** + +Create `drainable_test.go`: + +```go +package modular + +import ( + "context" + "testing" + "time" +) + +type drainableModule struct { + name string + preStopSeq *[]string + stopSeq *[]string +} + +func (m *drainableModule) Name() string { return m.name } +func (m *drainableModule) Init(app Application) error { return nil } +func (m *drainableModule) Start(ctx context.Context) error { return nil } +func (m *drainableModule) PreStop(ctx context.Context) error { + *m.preStopSeq = append(*m.preStopSeq, m.name) + return nil +} +func (m *drainableModule) Stop(ctx context.Context) error { + *m.stopSeq = append(*m.stopSeq, m.name) + return nil +} + +func TestDrainable_PreStopCalledBeforeStop(t *testing.T) { + preStops := make([]string, 0) + stops := make([]string, 0) + + mod := &drainableModule{name: "drainer", preStopSeq: &preStops, stopSeq: &stops} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(mod), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + + if len(preStops) != 1 || preStops[0] != "drainer" { + t.Errorf("expected PreStop called for drainer, got %v", preStops) + } + if len(stops) != 1 || stops[0] != "drainer" { + t.Errorf("expected Stop called for drainer, got %v", stops) + } +} + +func TestDrainable_WithDrainTimeout(t *testing.T) { + app, err := NewApplication( + WithLogger(nopLogger{}), + WithDrainTimeout(5*time.Second), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + stdApp, ok := app.(*StdApplication) + if !ok { + t.Skip("not a StdApplication") + } + + if stdApp.drainTimeout != 5*time.Second { + t.Errorf("expected drain timeout 5s, got %v", stdApp.drainTimeout) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestDrainable -count=1 -v` +Expected: FAIL — `Drainable` undefined, `WithDrainTimeout` undefined + +**Step 3: Implement** + +Create `drainable.go`: +```go +package modular + +import ( + "context" + "time" +) + +// Drainable is an optional interface for modules that need a pre-stop drain phase. +// During shutdown, PreStop is called on all Drainable modules (reverse dependency order) +// before Stop is called on Stoppable modules. This allows modules to stop accepting +// new work and drain in-flight requests before the hard stop. +type Drainable interface { + // PreStop initiates graceful drain before stop. The context carries the drain timeout. + PreStop(ctx context.Context) error +} + +// defaultDrainTimeout is the default timeout for the PreStop drain phase. +const defaultDrainTimeout = 15 * time.Second +``` + +Add `drainTimeout` field to `StdApplication` in `application.go`: +```go +drainTimeout time.Duration +``` + +Add `WithDrainTimeout` option in `builder.go`: +```go +// WithDrainTimeout sets the timeout for the PreStop drain phase during shutdown. +func WithDrainTimeout(d time.Duration) Option { + return func(b *ApplicationBuilder) error { + b.drainTimeout = d + return nil + } +} +``` + +Add `drainTimeout time.Duration` to `ApplicationBuilder`. + +In `Build()`, propagate to StdApplication: +```go +if b.drainTimeout > 0 { + if stdApp, ok := app.(*StdApplication); ok { + stdApp.drainTimeout = b.drainTimeout + } else if obsApp, ok := app.(*ObservableApplication); ok { + obsApp.drainTimeout = b.drainTimeout + } +} +``` + +Modify `Stop()` in `application.go` to call PreStop first: +```go +func (app *StdApplication) Stop() error { + modules, err := app.resolveDependencies() + if err != nil { + return err + } + slices.Reverse(modules) + + // Phase 1: Drain — call PreStop on all Drainable modules + drainTimeout := app.drainTimeout + if drainTimeout <= 0 { + drainTimeout = defaultDrainTimeout + } + drainCtx, drainCancel := context.WithTimeout(context.Background(), drainTimeout) + defer drainCancel() + + for _, name := range modules { + module := app.moduleRegistry[name] + drainableModule, ok := module.(Drainable) + if !ok { + continue + } + app.logger.Info("Draining module", "module", name) + if err := drainableModule.PreStop(drainCtx); err != nil { + app.logger.Error("Error draining module", "module", name, "error", err) + } + } + + // Phase 2: Stop — call Stop on all Stoppable modules + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var lastErr error + for _, name := range modules { + module := app.moduleRegistry[name] + stoppableModule, ok := module.(Stoppable) + if !ok { + app.logger.Debug("Module does not implement Stoppable, skipping", "module", name) + continue + } + app.logger.Info("Stopping module", "module", name) + if err = stoppableModule.Stop(ctx); err != nil { + app.logger.Error("Error stopping module", "module", name, "error", err) + lastErr = err + } + } + + if app.cancel != nil { + app.cancel() + } + return lastErr +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestDrainable -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add drainable.go drainable_test.go application.go builder.go +git commit -m "feat: add Drainable interface with PreStop drain phase" +``` + +--- + +### Task 3: Application Phase Tracking + +**Files:** +- Create: `phase.go` — AppPhase type, constants, String() +- Modify: `application.go` — add `phase` field, `Phase()` method, phase transitions +- Modify: `observer.go` — add `EventTypeAppPhaseChanged` constant +- Create: `phase_test.go` — tests + +**Step 1: Write the failing test** + +Create `phase_test.go`: + +```go +package modular + +import ( + "testing" +) + +func TestAppPhase_String(t *testing.T) { + tests := []struct { + phase AppPhase + want string + }{ + {PhaseCreated, "created"}, + {PhaseInitializing, "initializing"}, + {PhaseStarting, "starting"}, + {PhaseRunning, "running"}, + {PhaseDraining, "draining"}, + {PhaseStopping, "stopping"}, + {PhaseStopped, "stopped"}, + } + for _, tt := range tests { + if got := tt.phase.String(); got != tt.want { + t.Errorf("AppPhase(%d).String() = %q, want %q", tt.phase, got, tt.want) + } + } +} + +func TestPhaseTracking_LifecycleTransitions(t *testing.T) { + app, err := NewApplication( + WithLogger(nopLogger{}), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + stdApp := app.(*StdApplication) + + if stdApp.Phase() != PhaseCreated { + t.Errorf("expected PhaseCreated, got %v", stdApp.Phase()) + } + + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + // After Init, phase should be past initializing (at least initialized) + phase := stdApp.Phase() + if phase != PhaseInitialized { + t.Errorf("expected PhaseInitialized after Init, got %v", phase) + } + + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + if stdApp.Phase() != PhaseRunning { + t.Errorf("expected PhaseRunning after Start, got %v", stdApp.Phase()) + } + + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + if stdApp.Phase() != PhaseStopped { + t.Errorf("expected PhaseStopped after Stop, got %v", stdApp.Phase()) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestAppPhase -count=1 -v && go test -run TestPhaseTracking -count=1 -v` +Expected: FAIL — `AppPhase` undefined + +**Step 3: Implement** + +Create `phase.go`: +```go +package modular + +import "sync/atomic" + +// AppPhase represents the current lifecycle phase of the application. +type AppPhase int32 + +const ( + PhaseCreated AppPhase = iota + PhaseInitializing + PhaseInitialized + PhaseStarting + PhaseRunning + PhaseDraining + PhaseStopping + PhaseStopped +) + +func (p AppPhase) String() string { + switch p { + case PhaseCreated: + return "created" + case PhaseInitializing: + return "initializing" + case PhaseInitialized: + return "initialized" + case PhaseStarting: + return "starting" + case PhaseRunning: + return "running" + case PhaseDraining: + return "draining" + case PhaseStopping: + return "stopping" + case PhaseStopped: + return "stopped" + default: + return "unknown" + } +} +``` + +Add `phase atomic.Int32` field to `StdApplication`. Add `Phase()` method: +```go +func (app *StdApplication) Phase() AppPhase { + return AppPhase(app.phase.Load()) +} + +func (app *StdApplication) setPhase(p AppPhase) { + app.phase.Store(int32(p)) +} +``` + +Add `EventTypeAppPhaseChanged` to `observer.go`: +```go +EventTypeAppPhaseChanged = "com.modular.application.phase.changed" +``` + +In `InitWithApp()`, wrap with phase transitions: +```go +app.setPhase(PhaseInitializing) +// ... existing init logic ... +app.setPhase(PhaseInitialized) +``` + +In `Start()`: +```go +app.setPhase(PhaseStarting) +// ... existing start logic ... +app.setPhase(PhaseRunning) +``` + +In `Stop()`: +```go +app.setPhase(PhaseDraining) +// ... PreStop phase ... +app.setPhase(PhaseStopping) +// ... Stop phase ... +app.setPhase(PhaseStopped) +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run "TestAppPhase|TestPhaseTracking" -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add phase.go phase_test.go application.go observer.go +git commit -m "feat: add application phase tracking with lifecycle transitions" +``` + +--- + +### Task 4: Parallel Init at Same Topological Depth + +**Files:** +- Modify: `builder.go` — add `WithParallelInit` option +- Modify: `application.go` — parallel init logic using `errgroup` +- Create: `parallel_init_test.go` — tests + +**Step 1: Write the failing test** + +Create `parallel_init_test.go`: + +```go +package modular + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +type parallelInitModule struct { + name string + deps []string + initDelay time.Duration + initCount *atomic.Int32 + maxPar *atomic.Int32 + curPar *atomic.Int32 +} + +func (m *parallelInitModule) Name() string { return m.name } +func (m *parallelInitModule) Dependencies() []string { return m.deps } +func (m *parallelInitModule) Init(app Application) error { + cur := m.curPar.Add(1) + defer m.curPar.Add(-1) + + // Track max concurrency + for { + old := m.maxPar.Load() + if cur <= old || m.maxPar.CompareAndSwap(old, cur) { + break + } + } + + m.initCount.Add(1) + time.Sleep(m.initDelay) + return nil +} + +func TestWithParallelInit_ConcurrentSameDepth(t *testing.T) { + var initCount, maxPar, curPar atomic.Int32 + + // Three independent modules (no deps) — should init concurrently + modA := ¶llelInitModule{name: "a", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + modB := ¶llelInitModule{name: "b", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + modC := ¶llelInitModule{name: "c", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB, modC), + WithParallelInit(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + start := time.Now() + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + elapsed := time.Since(start) + + if initCount.Load() != 3 { + t.Errorf("expected 3 inits, got %d", initCount.Load()) + } + if maxPar.Load() < 2 { + t.Errorf("expected at least 2 concurrent inits, got max %d", maxPar.Load()) + } + // Should complete faster than 3 * 50ms sequential + if elapsed > 120*time.Millisecond { + t.Errorf("expected parallel init to be faster, took %v", elapsed) + } +} + +func TestWithParallelInit_RespectsDepOrder(t *testing.T) { + var mu sync.Mutex + order := make([]string, 0) + + makeModule := func(name string, deps []string) *simpleOrderModule { + return &simpleOrderModule{name: name, deps: deps, order: &order, mu: &mu} + } + + // dep → a, dep → b (a and b can be parallel, dep must be first) + modDep := makeModule("dep", nil) + modA := makeModule("a", []string{"dep"}) + modB := makeModule("b", []string{"dep"}) + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modDep, modA, modB), + WithParallelInit(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(order) != 3 || order[0] != "dep" { + t.Errorf("expected dep first, got order %v", order) + } +} + +type simpleOrderModule struct { + name string + deps []string + order *[]string + mu *sync.Mutex +} + +func (m *simpleOrderModule) Name() string { return m.name } +func (m *simpleOrderModule) Dependencies() []string { return m.deps } +func (m *simpleOrderModule) Init(app Application) error { + m.mu.Lock() + *m.order = append(*m.order, m.name) + m.mu.Unlock() + return nil +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestWithParallelInit -count=1 -v` +Expected: FAIL — `WithParallelInit` undefined + +**Step 3: Implement** + +Add `parallelInit bool` field to `ApplicationBuilder` and `StdApplication`. + +Add builder option: +```go +// WithParallelInit enables concurrent initialization of modules at the same +// topological depth in the dependency graph. Disabled by default. +func WithParallelInit() Option { + return func(b *ApplicationBuilder) error { + b.parallelInit = true + return nil + } +} +``` + +Propagate in `Build()` similar to other fields. + +In `application.go`, add a method to compute topological depth levels: +```go +// computeDepthLevels groups modules by their topological depth. +// Level 0 has no dependencies, level 1 depends only on level 0, etc. +func (app *StdApplication) computeDepthLevels(order []string) [][]string { + depth := make(map[string]int) + graph := make(map[string][]string) + + // Rebuild graph for depth calculation + for _, name := range order { + module := app.moduleRegistry[name] + if depAware, ok := module.(DependencyAware); ok { + graph[name] = depAware.Dependencies() + } + // Include config-driven hints + for _, hint := range app.dependencyHints { + if hint.From == name { + graph[name] = append(graph[name], hint.To) + } + } + } + + // Compute depths + var computeDepth func(string) int + computeDepth = func(name string) int { + if d, ok := depth[name]; ok { + return d + } + maxDep := 0 + for _, dep := range graph[name] { + if d := computeDepth(dep) + 1; d > maxDep { + maxDep = d + } + } + depth[name] = maxDep + return maxDep + } + + for _, name := range order { + computeDepth(name) + } + + // Group by depth + maxDepth := 0 + for _, d := range depth { + if d > maxDepth { + maxDepth = d + } + } + + levels := make([][]string, maxDepth+1) + for _, name := range order { + d := depth[name] + levels[d] = append(levels[d], name) + } + return levels +} +``` + +Modify `InitWithApp` to use parallel init when enabled. Replace the sequential init loop with: +```go +if app.parallelInit { + levels := app.computeDepthLevels(moduleOrder) + for _, level := range levels { + if len(level) == 1 { + // Single module — init sequentially (no goroutine overhead) + if err := app.initModule(appToPass, level[0]); err != nil { + errs = append(errs, err) + } + } else { + // Multiple modules at same depth — init concurrently + var levelErrs []error + var mu sync.Mutex + var wg sync.WaitGroup + for _, moduleName := range level { + wg.Add(1) + go func(name string) { + defer wg.Done() + if err := app.initModule(appToPass, name); err != nil { + mu.Lock() + levelErrs = append(levelErrs, err) + mu.Unlock() + } + }(moduleName) + } + wg.Wait() + errs = append(errs, levelErrs...) + } + } +} else { + // Sequential init (existing behavior) + for _, moduleName := range moduleOrder { + if err := app.initModule(appToPass, moduleName); err != nil { + errs = append(errs, err) + } + } +} +``` + +Extract the per-module init logic into a helper: +```go +func (app *StdApplication) initModule(appToPass Application, moduleName string) error { + var err error + module := app.moduleRegistry[moduleName] + + if _, ok := module.(ServiceAware); ok { + app.moduleRegistry[moduleName], err = app.injectServices(module) + if err != nil { + return fmt.Errorf("failed to inject services for module '%s': %w", moduleName, err) + } + module = app.moduleRegistry[moduleName] + } + + if app.enhancedSvcRegistry != nil { + app.enhancedSvcRegistry.SetCurrentModule(module) + } + + if err = module.Init(appToPass); err != nil { + if app.enhancedSvcRegistry != nil { + app.enhancedSvcRegistry.ClearCurrentModule() + } + return fmt.Errorf("module '%s' failed to initialize: %w", moduleName, err) + } + + if svcAware, ok := module.(ServiceAware); ok { + for _, svc := range svcAware.ProvidesServices() { + if err = app.RegisterService(svc.Name, svc.Instance); err != nil { + if app.enhancedSvcRegistry != nil { + app.enhancedSvcRegistry.ClearCurrentModule() + } + return fmt.Errorf("module '%s' failed to register service '%s': %w", moduleName, svc.Name, err) + } + } + } + + if app.enhancedSvcRegistry != nil { + app.enhancedSvcRegistry.ClearCurrentModule() + } + + app.logger.Info(fmt.Sprintf("Initialized module %s of type %T", moduleName, app.moduleRegistry[moduleName])) + return nil +} +``` + +**Note:** When parallel init is enabled, `SetCurrentModule`/`ClearCurrentModule` need mutex protection. Add a mutex to the init path or guard the enhanced registry calls. + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestWithParallelInit -count=1 -v` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `cd /tmp/gca-modular && go test ./... -count=1` +Expected: All existing tests pass + +**Step 6: Commit** + +```bash +cd /tmp/gca-modular +git add builder.go application.go parallel_init_test.go +git commit -m "feat: add WithParallelInit for concurrent module initialization" +``` + +--- + +### Task 5: Type-Safe Service Helpers (Generics) + +**Files:** +- Create: `service_typed.go` — generic helper functions +- Create: `service_typed_test.go` — tests + +**Step 1: Write the failing test** + +Create `service_typed_test.go`: + +```go +package modular + +import ( + "testing" +) + +type testService struct { + Value string +} + +func TestRegisterTypedService_and_GetTypedService(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + + svc := &testService{Value: "hello"} + if err := RegisterTypedService[*testService](app, "test.svc", svc); err != nil { + t.Fatalf("RegisterTypedService: %v", err) + } + + got, err := GetTypedService[*testService](app, "test.svc") + if err != nil { + t.Fatalf("GetTypedService: %v", err) + } + if got.Value != "hello" { + t.Errorf("expected hello, got %s", got.Value) + } +} + +func TestGetTypedService_WrongType(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + _ = RegisterTypedService[string](app, "str.svc", "hello") + + _, err := GetTypedService[int](app, "str.svc") + if err == nil { + t.Fatal("expected type mismatch error") + } +} + +func TestGetTypedService_NotFound(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + + _, err := GetTypedService[string](app, "missing") + if err == nil { + t.Fatal("expected not found error") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestRegisterTypedService -count=1 -v && go test -run TestGetTypedService -count=1 -v` +Expected: FAIL — `RegisterTypedService` undefined + +**Step 3: Implement** + +Create `service_typed.go`: + +```go +package modular + +import "fmt" + +// RegisterTypedService registers a service with compile-time type safety. +// This is a package-level helper that wraps Application.RegisterService. +func RegisterTypedService[T any](app Application, name string, svc T) error { + return app.RegisterService(name, svc) +} + +// GetTypedService retrieves a service with compile-time type safety. +// Returns the zero value of T and an error if the service is not found +// or cannot be cast to the expected type. +func GetTypedService[T any](app Application, name string) (T, error) { + var zero T + svcRegistry := app.SvcRegistry() + raw, exists := svcRegistry[name] + if !exists { + return zero, fmt.Errorf("%w: %s", ErrServiceNotFound, name) + } + typed, ok := raw.(T) + if !ok { + return zero, fmt.Errorf("%w: service %q is %T, want %T", ErrServiceWrongType, name, raw, zero) + } + return typed, nil +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run "TestRegisterTypedService|TestGetTypedService" -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add service_typed.go service_typed_test.go +git commit -m "feat: add RegisterTypedService/GetTypedService generic helpers" +``` + +--- + +### Task 6: Service Readiness Events & OnServiceReady + +**Files:** +- Modify: `service.go` — add `OnServiceReady` method to `EnhancedServiceRegistry` +- Create: `service_readiness_test.go` — tests + +**Step 1: Write the failing test** + +Create `service_readiness_test.go`: + +```go +package modular + +import ( + "sync/atomic" + "testing" +) + +func TestOnServiceReady_AlreadyRegistered(t *testing.T) { + registry := NewEnhancedServiceRegistry() + registry.RegisterService("db", "postgres-conn") + + var called atomic.Bool + registry.OnServiceReady("db", func(svc any) { + called.Store(true) + if svc != "postgres-conn" { + t.Errorf("expected postgres-conn, got %v", svc) + } + }) + + if !called.Load() { + t.Error("callback should have been called immediately") + } +} + +func TestOnServiceReady_DeferredUntilRegistration(t *testing.T) { + registry := NewEnhancedServiceRegistry() + + var called atomic.Bool + var receivedSvc any + registry.OnServiceReady("db", func(svc any) { + called.Store(true) + receivedSvc = svc + }) + + if called.Load() { + t.Error("callback should not have been called yet") + } + + registry.RegisterService("db", "postgres-conn") + + if !called.Load() { + t.Error("callback should have been called after registration") + } + if receivedSvc != "postgres-conn" { + t.Errorf("expected postgres-conn, got %v", receivedSvc) + } +} + +func TestOnServiceReady_MultipleCallbacks(t *testing.T) { + registry := NewEnhancedServiceRegistry() + + var count atomic.Int32 + registry.OnServiceReady("cache", func(svc any) { count.Add(1) }) + registry.OnServiceReady("cache", func(svc any) { count.Add(1) }) + + registry.RegisterService("cache", "redis") + + if count.Load() != 2 { + t.Errorf("expected 2 callbacks, got %d", count.Load()) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestOnServiceReady -count=1 -v` +Expected: FAIL — `OnServiceReady` undefined + +**Step 3: Implement** + +Add to `EnhancedServiceRegistry`: +```go +// readyCallbacks maps service names to pending callbacks. +readyCallbacks map[string][]func(any) +``` + +Initialize in `NewEnhancedServiceRegistry`: +```go +readyCallbacks: make(map[string][]func(any)), +``` + +Add the method: +```go +// OnServiceReady registers a callback that fires when the named service is registered. +// If the service is already registered, the callback fires immediately. +func (r *EnhancedServiceRegistry) OnServiceReady(name string, callback func(any)) { + if entry, exists := r.services[name]; exists { + callback(entry.Service) + return + } + r.readyCallbacks[name] = append(r.readyCallbacks[name], callback) +} +``` + +Modify `RegisterService` to fire pending callbacks after registration: +```go +// After r.services[actualName] = entry, add: +// Fire readiness callbacks for the original name and the actual name. +for _, cbName := range []string{originalName, actualName} { + if callbacks, ok := r.readyCallbacks[cbName]; ok { + for _, cb := range callbacks { + cb(service) + } + delete(r.readyCallbacks, cbName) + } +} +``` + +Note: Use `originalName` as the variable name for the first parameter to `RegisterService` (it's called `name` in the current code — rename to `originalName` for clarity, or just use `name`). + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestOnServiceReady -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add service.go service_readiness_test.go +git commit -m "feat: add OnServiceReady callback for service readiness events" +``` + +--- + +### Task 7: Plugin Interface & WithPlugins + +**Files:** +- Create: `plugin.go` — Plugin, PluginWithHooks, PluginWithServices interfaces + ServiceDefinition +- Modify: `builder.go` — add `WithPlugins` option +- Create: `plugin_test.go` — tests + +**Step 1: Write the failing test** + +Create `plugin_test.go`: + +```go +package modular + +import ( + "testing" +) + +type testPlugin struct { + modules []Module + services []ServiceDefinition + hookRan bool +} + +func (p *testPlugin) Name() string { return "test-plugin" } +func (p *testPlugin) Modules() []Module { return p.modules } +func (p *testPlugin) Services() []ServiceDefinition { return p.services } +func (p *testPlugin) InitHooks() []func(Application) error { + return []func(Application) error{ + func(app Application) error { + p.hookRan = true + return nil + }, + } +} + +type pluginModule struct { + name string + initialized bool +} + +func (m *pluginModule) Name() string { return m.name } +func (m *pluginModule) Init(app Application) error { m.initialized = true; return nil } + +func TestWithPlugins_RegistersModulesAndServices(t *testing.T) { + mod := &pluginModule{name: "plugin-mod"} + plugin := &testPlugin{ + modules: []Module{mod}, + services: []ServiceDefinition{{Name: "plugin.svc", Service: "hello"}}, + } + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithPlugins(plugin), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + if !mod.initialized { + t.Error("plugin module should have been initialized") + } + if !plugin.hookRan { + t.Error("plugin hook should have run") + } + + svc, err := GetTypedService[string](app, "plugin.svc") + if err != nil { + t.Fatalf("GetTypedService: %v", err) + } + if svc != "hello" { + t.Errorf("expected hello, got %s", svc) + } +} + +// Test a simple plugin (no hooks, no services) +type simplePlugin struct { + modules []Module +} + +func (p *simplePlugin) Name() string { return "simple" } +func (p *simplePlugin) Modules() []Module { return p.modules } + +func TestWithPlugins_SimplePlugin(t *testing.T) { + mod := &pluginModule{name: "simple-mod"} + plugin := &simplePlugin{modules: []Module{mod}} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithPlugins(plugin), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + if !mod.initialized { + t.Error("plugin module should have been initialized") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestWithPlugins -count=1 -v` +Expected: FAIL — `Plugin` undefined + +**Step 3: Implement** + +Create `plugin.go`: +```go +package modular + +// Plugin is the minimal interface for a plugin bundle that provides modules. +type Plugin interface { + Name() string + Modules() []Module +} + +// PluginWithHooks extends Plugin with initialization hooks. +type PluginWithHooks interface { + Plugin + InitHooks() []func(Application) error +} + +// PluginWithServices extends Plugin with service definitions. +type PluginWithServices interface { + Plugin + Services() []ServiceDefinition +} + +// ServiceDefinition describes a service provided by a plugin. +type ServiceDefinition struct { + Name string + Service any +} +``` + +Add `plugins []Plugin` to `ApplicationBuilder`. Add option: +```go +// WithPlugins registers plugins with the application. Each plugin's modules +// are registered, hooks are added as config-loaded hooks, and services are +// registered before module init. +func WithPlugins(plugins ...Plugin) Option { + return func(b *ApplicationBuilder) error { + b.plugins = append(b.plugins, plugins...) + return nil + } +} +``` + +In `Build()`, after creating the app, process plugins: +```go +for _, plugin := range b.plugins { + // Register plugin modules + for _, mod := range plugin.Modules() { + app.RegisterModule(mod) + } + + // Register plugin services + if withSvc, ok := plugin.(PluginWithServices); ok { + for _, svcDef := range withSvc.Services() { + if err := app.RegisterService(svcDef.Name, svcDef.Service); err != nil { + return nil, fmt.Errorf("plugin %q service %q: %w", plugin.Name(), svcDef.Name, err) + } + } + } + + // Register plugin hooks + if withHooks, ok := plugin.(PluginWithHooks); ok { + for _, hook := range withHooks.InitHooks() { + app.OnConfigLoaded(hook) + } + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestWithPlugins -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add plugin.go plugin_test.go builder.go +git commit -m "feat: add Plugin interface with WithPlugins builder option" +``` + +--- + +### Task 8: ReloadOrchestrator Integration (`WithDynamicReload`) + +**Files:** +- Modify: `builder.go` — add `WithDynamicReload` option +- Modify: `application.go` — wire orchestrator into Start/Stop, expose `RequestReload` +- Create: `reload_integration_test.go` — tests + +**Step 1: Write the failing test** + +Create `reload_integration_test.go`: + +```go +package modular + +import ( + "context" + "sync/atomic" + "testing" + "time" +) + +type reloadableTestModule struct { + name string + reloadCount atomic.Int32 +} + +func (m *reloadableTestModule) Name() string { return m.name } +func (m *reloadableTestModule) Init(app Application) error { return nil } +func (m *reloadableTestModule) CanReload() bool { return true } +func (m *reloadableTestModule) ReloadTimeout() time.Duration { return 5 * time.Second } +func (m *reloadableTestModule) Reload(ctx context.Context, changes []ConfigChange) error { + m.reloadCount.Add(1) + return nil +} + +func TestWithDynamicReload_WiresOrchestrator(t *testing.T) { + mod := &reloadableTestModule{name: "hot-mod"} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(mod), + WithDynamicReload(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + + stdApp := app.(*StdApplication) + + // Request a reload + diff := ConfigDiff{ + Changed: map[string]FieldChange{ + "key": {OldValue: "old", NewValue: "new", FieldPath: "key", ChangeType: ChangeModified}, + }, + DiffID: "test-diff", + } + err = stdApp.RequestReload(context.Background(), ReloadManual, diff) + if err != nil { + t.Fatalf("RequestReload: %v", err) + } + + // Wait for reload to process + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if mod.reloadCount.Load() > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + + if mod.reloadCount.Load() == 0 { + t.Error("expected module to be reloaded") + } + + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestWithDynamicReload -count=1 -v` +Expected: FAIL — `WithDynamicReload` undefined + +**Step 3: Implement** + +Add `dynamicReload bool` to `ApplicationBuilder`. +Add `reloadOrchestrator *ReloadOrchestrator` to `StdApplication`. + +Builder option: +```go +// WithDynamicReload enables the ReloadOrchestrator, wiring it into the +// application lifecycle. Reloadable modules are auto-registered after Init, +// and the orchestrator starts/stops with the application. +func WithDynamicReload() Option { + return func(b *ApplicationBuilder) error { + b.dynamicReload = true + return nil + } +} +``` + +In `Build()`, propagate: +```go +if b.dynamicReload { + if stdApp, ok := app.(*StdApplication); ok { + stdApp.dynamicReload = true + } else if obsApp, ok := app.(*ObservableApplication); ok { + obsApp.dynamicReload = true + } +} +``` + +Add `dynamicReload bool` field to `StdApplication`. + +In `InitWithApp`, after all modules are initialized (before marking initialized), register reloadables: +```go +if app.dynamicReload { + var subject Subject + if obsApp, ok := appToPass.(*ObservableApplication); ok { + subject = obsApp + } + app.reloadOrchestrator = NewReloadOrchestrator(app.logger, subject) + for name, module := range app.moduleRegistry { + if reloadable, ok := module.(Reloadable); ok { + app.reloadOrchestrator.RegisterReloadable(name, reloadable) + } + } +} +``` + +In `Start()`, after starting all modules: +```go +if app.reloadOrchestrator != nil { + app.reloadOrchestrator.Start(ctx) +} +``` + +In `Stop()`, before draining: +```go +if app.reloadOrchestrator != nil { + app.reloadOrchestrator.Stop() +} +``` + +Add `RequestReload` method: +```go +// RequestReload enqueues a reload request. Only available when WithDynamicReload is enabled. +func (app *StdApplication) RequestReload(ctx context.Context, trigger ReloadTrigger, diff ConfigDiff) error { + if app.reloadOrchestrator == nil { + return fmt.Errorf("dynamic reload not enabled") + } + return app.reloadOrchestrator.RequestReload(ctx, trigger, diff) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestWithDynamicReload -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add builder.go application.go reload_integration_test.go +git commit -m "feat: add WithDynamicReload to wire ReloadOrchestrator into app lifecycle" +``` + +--- + +### Task 9: Secret Resolution Hooks + +**Files:** +- Create: `secret_resolver.go` — SecretResolver interface + ExpandSecrets utility +- Create: `secret_resolver_test.go` — tests + +**Step 1: Write the failing test** + +Create `secret_resolver_test.go`: + +```go +package modular + +import ( + "context" + "strings" + "testing" +) + +type mockResolver struct { + prefix string + values map[string]string +} + +func (r *mockResolver) CanResolve(ref string) bool { + return strings.HasPrefix(ref, r.prefix+":") +} + +func (r *mockResolver) ResolveSecret(ctx context.Context, ref string) (string, error) { + key := strings.TrimPrefix(ref, r.prefix+":") + if v, ok := r.values[key]; ok { + return v, nil + } + return "", ErrServiceNotFound +} + +func TestExpandSecrets_ResolvesRefs(t *testing.T) { + resolver := &mockResolver{ + prefix: "vault", + values: map[string]string{ + "secret/db-pass": "s3cret", + }, + } + + config := map[string]any{ + "host": "localhost", + "password": "${vault:secret/db-pass}", + "nested": map[string]any{ + "key": "${vault:secret/db-pass}", + }, + } + + err := ExpandSecrets(context.Background(), config, resolver) + if err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + + if config["password"] != "s3cret" { + t.Errorf("expected s3cret, got %v", config["password"]) + } + nested := config["nested"].(map[string]any) + if nested["key"] != "s3cret" { + t.Errorf("expected nested s3cret, got %v", nested["key"]) + } +} + +func TestExpandSecrets_SkipsNonRefs(t *testing.T) { + config := map[string]any{ + "host": "localhost", + "port": 5432, + } + + err := ExpandSecrets(context.Background(), config) + if err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + + if config["host"] != "localhost" { + t.Errorf("expected localhost, got %v", config["host"]) + } +} + +func TestExpandSecrets_NoMatchingResolver(t *testing.T) { + config := map[string]any{ + "password": "${aws:secret/key}", + } + + resolver := &mockResolver{prefix: "vault", values: map[string]string{}} + + // No matching resolver — value should remain unchanged + err := ExpandSecrets(context.Background(), config, resolver) + if err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + if config["password"] != "${aws:secret/key}" { + t.Errorf("expected unchanged ref, got %v", config["password"]) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestExpandSecrets -count=1 -v` +Expected: FAIL — `SecretResolver` undefined + +**Step 3: Implement** + +Create `secret_resolver.go`: + +```go +package modular + +import ( + "context" + "fmt" + "regexp" +) + +// SecretResolver resolves secret references in configuration values. +// Implementations connect to secret stores (Vault, AWS Secrets Manager, etc.) +type SecretResolver interface { + // ResolveSecret resolves a secret reference string to its actual value. + ResolveSecret(ctx context.Context, ref string) (string, error) + + // CanResolve reports whether this resolver handles the given reference. + CanResolve(ref string) bool +} + +// secretRefPattern matches ${prefix:path} patterns in config values. +var secretRefPattern = regexp.MustCompile(`^\$\{([^:}]+:[^}]+)\}$`) + +// ExpandSecrets walks a config map and replaces string values matching +// ${prefix:path} with the resolved secret value. It recurses into nested +// maps. Values that don't match or have no matching resolver are left unchanged. +func ExpandSecrets(ctx context.Context, config map[string]any, resolvers ...SecretResolver) error { + for key, val := range config { + switch v := val.(type) { + case string: + resolved, err := resolveSecretString(ctx, v, resolvers) + if err != nil { + return fmt.Errorf("resolving %q: %w", key, err) + } + config[key] = resolved + case map[string]any: + if err := ExpandSecrets(ctx, v, resolvers...); err != nil { + return err + } + } + } + return nil +} + +func resolveSecretString(ctx context.Context, val string, resolvers []SecretResolver) (string, error) { + match := secretRefPattern.FindStringSubmatch(val) + if match == nil { + return val, nil + } + ref := match[1] + for _, r := range resolvers { + if r.CanResolve(ref) { + return r.ResolveSecret(ctx, ref) + } + } + // No matching resolver — return unchanged + return val, nil +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestExpandSecrets -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add secret_resolver.go secret_resolver_test.go +git commit -m "feat: add SecretResolver interface and ExpandSecrets utility" +``` + +--- + +### Task 10: Config File Watcher Module + +**Files:** +- Create: `modules/configwatcher/configwatcher.go` — module implementation +- Create: `modules/configwatcher/configwatcher_test.go` — tests +- Modify: `go.mod` — add `github.com/fsnotify/fsnotify` dependency + +**Step 1: Add fsnotify dependency** + +Run: `cd /tmp/gca-modular && go get github.com/fsnotify/fsnotify` + +**Step 2: Write the test** + +Create `modules/configwatcher/configwatcher_test.go`: + +```go +package configwatcher + +import ( + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestConfigWatcher_DetectsFileChange(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgFile, []byte("key: value1"), 0644); err != nil { + t.Fatal(err) + } + + var changeCount atomic.Int32 + w := New( + WithPaths(cfgFile), + WithDebounce(50*time.Millisecond), + WithOnChange(func(paths []string) { + changeCount.Add(1) + }), + ) + + if err := w.startWatching(); err != nil { + t.Fatalf("startWatching: %v", err) + } + defer w.stopWatching() + + // Modify the file + time.Sleep(100 * time.Millisecond) + if err := os.WriteFile(cfgFile, []byte("key: value2"), 0644); err != nil { + t.Fatal(err) + } + + // Wait for debounce + processing + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if changeCount.Load() > 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + + if changeCount.Load() == 0 { + t.Error("expected at least one change notification") + } +} + +func TestConfigWatcher_Debounces(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgFile, []byte("v1"), 0644); err != nil { + t.Fatal(err) + } + + var changeCount atomic.Int32 + w := New( + WithPaths(cfgFile), + WithDebounce(200*time.Millisecond), + WithOnChange(func(paths []string) { + changeCount.Add(1) + }), + ) + + if err := w.startWatching(); err != nil { + t.Fatalf("startWatching: %v", err) + } + defer w.stopWatching() + + time.Sleep(100 * time.Millisecond) + // Rapid-fire writes + for i := 0; i < 5; i++ { + os.WriteFile(cfgFile, []byte("v"+string(rune('2'+i))), 0644) + time.Sleep(20 * time.Millisecond) + } + + // Wait for debounce + time.Sleep(500 * time.Millisecond) + + if changeCount.Load() > 2 { + t.Errorf("expected debounced to ~1-2 calls, got %d", changeCount.Load()) + } +} +``` + +**Step 3: Implement** + +Create `modules/configwatcher/configwatcher.go`: + +```go +// Package configwatcher provides a module that watches configuration files +// for changes and triggers reload via a callback. +package configwatcher + +import ( + "context" + "sync" + "time" + + "github.com/fsnotify/fsnotify" +) + +// ConfigWatcher watches config files and calls OnChange when modifications are detected. +type ConfigWatcher struct { + paths []string + debounce time.Duration + onChange func(paths []string) + watcher *fsnotify.Watcher + stopCh chan struct{} + stopOnce sync.Once +} + +// Option configures a ConfigWatcher. +type Option func(*ConfigWatcher) + +// WithPaths sets the file paths to watch. +func WithPaths(paths ...string) Option { + return func(w *ConfigWatcher) { + w.paths = append(w.paths, paths...) + } +} + +// WithDebounce sets the debounce duration for file change events. +func WithDebounce(d time.Duration) Option { + return func(w *ConfigWatcher) { + w.debounce = d + } +} + +// WithOnChange sets the callback invoked when watched files change. +func WithOnChange(fn func(paths []string)) Option { + return func(w *ConfigWatcher) { + w.onChange = fn + } +} + +// New creates a new ConfigWatcher with the given options. +func New(opts ...Option) *ConfigWatcher { + w := &ConfigWatcher{ + debounce: 500 * time.Millisecond, + stopCh: make(chan struct{}), + } + for _, opt := range opts { + opt(w) + } + return w +} + +// Name returns the module name. +func (w *ConfigWatcher) Name() string { return "configwatcher" } + +// Init is a no-op for the config watcher module. +func (w *ConfigWatcher) Init(_ interface{ Logger() interface{ Info(string, ...any) } }) error { + return nil +} + +// Start begins watching the configured paths. +func (w *ConfigWatcher) Start(ctx context.Context) error { + if err := w.startWatching(); err != nil { + return err + } + go func() { + select { + case <-ctx.Done(): + w.stopWatching() + case <-w.stopCh: + } + }() + return nil +} + +// Stop stops the file watcher. +func (w *ConfigWatcher) Stop(_ context.Context) error { + w.stopWatching() + return nil +} + +func (w *ConfigWatcher) startWatching() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + w.watcher = watcher + + for _, path := range w.paths { + if err := watcher.Add(path); err != nil { + watcher.Close() + return err + } + } + + go w.eventLoop() + return nil +} + +func (w *ConfigWatcher) stopWatching() { + w.stopOnce.Do(func() { + close(w.stopCh) + if w.watcher != nil { + w.watcher.Close() + } + }) +} + +func (w *ConfigWatcher) eventLoop() { + var timer *time.Timer + changedPaths := make(map[string]struct{}) + + for { + select { + case event, ok := <-w.watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { + changedPaths[event.Name] = struct{}{} + if timer != nil { + timer.Stop() + } + timer = time.AfterFunc(w.debounce, func() { + if w.onChange != nil { + paths := make([]string, 0, len(changedPaths)) + for p := range changedPaths { + paths = append(paths, p) + } + changedPaths = make(map[string]struct{}) + w.onChange(paths) + } + }) + } + case _, ok := <-w.watcher.Errors: + if !ok { + return + } + case <-w.stopCh: + if timer != nil { + timer.Stop() + } + return + } + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test ./modules/configwatcher/... -count=1 -v` +Expected: PASS + +**Step 5: Run `go mod tidy`** + +Run: `cd /tmp/gca-modular && go mod tidy` + +**Step 6: Commit** + +```bash +cd /tmp/gca-modular +git add modules/configwatcher/ go.mod go.sum +git commit -m "feat: add configwatcher module with fsnotify file watching" +``` + +--- + +### Task 11: Slog Adapter + +**Files:** +- Create: `slog_adapter.go` — SlogAdapter implementation +- Create: `slog_adapter_test.go` — tests + +**Step 1: Write the failing test** + +Create `slog_adapter_test.go`: + +```go +package modular + +import ( + "bytes" + "log/slog" + "strings" + "testing" +) + +func TestSlogAdapter_ImplementsLogger(t *testing.T) { + var _ Logger = (*SlogAdapter)(nil) // compile-time check +} + +func TestSlogAdapter_DelegatesToSlog(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := slog.New(handler) + + adapter := NewSlogAdapter(logger) + adapter.Info("test info", "key", "value") + adapter.Error("test error", "err", "fail") + adapter.Warn("test warn") + adapter.Debug("test debug") + + output := buf.String() + if !strings.Contains(output, "test info") { + t.Error("expected info message in output") + } + if !strings.Contains(output, "test error") { + t.Error("expected error message in output") + } + if !strings.Contains(output, "test warn") { + t.Error("expected warn message in output") + } + if !strings.Contains(output, "test debug") { + t.Error("expected debug message in output") + } +} + +func TestSlogAdapter_With(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := slog.New(handler) + + adapter := NewSlogAdapter(logger).With("module", "test") + adapter.Info("with test") + + output := buf.String() + if !strings.Contains(output, "module=test") { + t.Errorf("expected module=test in output, got: %s", output) + } +} + +func TestSlogAdapter_WithGroup(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := slog.New(handler) + + adapter := NewSlogAdapter(logger).WithGroup("mygroup") + adapter.Info("group test", "key", "val") + + output := buf.String() + if !strings.Contains(output, "mygroup") { + t.Errorf("expected mygroup in output, got: %s", output) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestSlogAdapter -count=1 -v` +Expected: FAIL — `SlogAdapter` undefined + +**Step 3: Implement** + +Create `slog_adapter.go`: + +```go +package modular + +import "log/slog" + +// SlogAdapter wraps a *slog.Logger to implement the Logger interface. +// This allows using Go's standard structured logger with the modular framework. +type SlogAdapter struct { + logger *slog.Logger +} + +// NewSlogAdapter creates a new SlogAdapter wrapping the given slog.Logger. +func NewSlogAdapter(l *slog.Logger) *SlogAdapter { + return &SlogAdapter{logger: l} +} + +// Info logs at info level. +func (a *SlogAdapter) Info(msg string, args ...any) { a.logger.Info(msg, args...) } + +// Error logs at error level. +func (a *SlogAdapter) Error(msg string, args ...any) { a.logger.Error(msg, args...) } + +// Warn logs at warn level. +func (a *SlogAdapter) Warn(msg string, args ...any) { a.logger.Warn(msg, args...) } + +// Debug logs at debug level. +func (a *SlogAdapter) Debug(msg string, args ...any) { a.logger.Debug(msg, args...) } + +// With returns a new SlogAdapter with the given key-value pairs added to the context. +func (a *SlogAdapter) With(args ...any) *SlogAdapter { + return &SlogAdapter{logger: a.logger.With(args...)} +} + +// WithGroup returns a new SlogAdapter with the given group name. +func (a *SlogAdapter) WithGroup(name string) *SlogAdapter { + return &SlogAdapter{logger: a.logger.WithGroup(name)} +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestSlogAdapter -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add slog_adapter.go slog_adapter_test.go +git commit -m "feat: add SlogAdapter wrapping *slog.Logger for Logger interface" +``` + +--- + +### Task 12: Module Metrics Hooks + +**Files:** +- Create: `metrics.go` — MetricsProvider interface, ModuleMetrics type, CollectAllMetrics +- Modify: `application.go` — add `CollectAllMetrics` method +- Create: `metrics_test.go` — tests + +**Step 1: Write the failing test** + +Create `metrics_test.go`: + +```go +package modular + +import ( + "context" + "testing" +) + +type metricsModule struct { + name string +} + +func (m *metricsModule) Name() string { return m.name } +func (m *metricsModule) Init(app Application) error { return nil } +func (m *metricsModule) CollectMetrics(ctx context.Context) ModuleMetrics { + return ModuleMetrics{ + Name: m.name, + Values: map[string]float64{ + "requests_total": 100, + "error_rate": 0.02, + }, + } +} + +func TestCollectAllMetrics(t *testing.T) { + modA := &metricsModule{name: "api"} + modB := &pluginModule{name: "no-metrics"} // doesn't implement MetricsProvider + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + stdApp := app.(*StdApplication) + metrics := stdApp.CollectAllMetrics(context.Background()) + + if len(metrics) != 1 { + t.Fatalf("expected 1 metrics result, got %d", len(metrics)) + } + if metrics[0].Name != "api" { + t.Errorf("expected api, got %s", metrics[0].Name) + } + if metrics[0].Values["requests_total"] != 100 { + t.Errorf("expected requests_total=100, got %v", metrics[0].Values["requests_total"]) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestCollectAllMetrics -count=1 -v` +Expected: FAIL — `MetricsProvider` undefined + +**Step 3: Implement** + +Create `metrics.go`: + +```go +package modular + +import "context" + +// ModuleMetrics holds metrics collected from a single module. +type ModuleMetrics struct { + Name string + Values map[string]float64 +} + +// MetricsProvider is an optional interface for modules that expose operational metrics. +// The framework collects metrics from all implementing modules on demand. +type MetricsProvider interface { + CollectMetrics(ctx context.Context) ModuleMetrics +} +``` + +Add method to `application.go`: + +```go +// CollectAllMetrics gathers metrics from all modules implementing MetricsProvider. +func (app *StdApplication) CollectAllMetrics(ctx context.Context) []ModuleMetrics { + var results []ModuleMetrics + for _, module := range app.moduleRegistry { + if mp, ok := module.(MetricsProvider); ok { + results = append(results, mp.CollectMetrics(ctx)) + } + } + return results +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestCollectAllMetrics -count=1 -v` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `cd /tmp/gca-modular && go test ./... -count=1` +Expected: All tests pass + +**Step 6: Commit** + +```bash +cd /tmp/gca-modular +git add metrics.go metrics_test.go application.go +git commit -m "feat: add MetricsProvider interface and CollectAllMetrics" +``` + +--- + +## Post-Implementation + +After all 12 tasks are complete: + +1. Run full test suite: `cd /tmp/gca-modular && go test ./... -count=1 -race` +2. Run linter: `cd /tmp/gca-modular && golangci-lint run` +3. Run vet: `cd /tmp/gca-modular && go vet ./...` +4. Fix any issues found + +All work is on the `feat/reimplementation` branch. Create a PR against `main` when complete. diff --git a/docs/plans/aggregate-health.md b/docs/plans/aggregate-health.md new file mode 100644 index 00000000..7f3ae47b --- /dev/null +++ b/docs/plans/aggregate-health.md @@ -0,0 +1,107 @@ +# Aggregate Health Service — Revised Implementation Plan + +> Reset from CrisisTextLine/modular upstream (2026-03-09). This revision reflects what already exists. + +## Gap Analysis + +**Already exists (~15%):** +- ReverseProxy `HealthChecker` with concurrent backend checks, events, debug endpoints (`modules/reverseproxy/health_checker.go`) +- Backend health events: `EventTypeBackendHealthy`, `EventTypeBackendUnhealthy` (`modules/reverseproxy/events.go`) +- Observer pattern with CloudEvents (`observer.go`) — event emission infrastructure +- ReverseProxy circuit breaker (`modules/reverseproxy/circuit_breaker.go`) +- Database BDD health check stubs (`modules/database/bdd_connections_test.go`) +- HTTP server health monitoring BDD stubs (`modules/httpserver/bdd_health_monitoring_test.go`) + +**Must implement (entire core service is new):** +- `HealthStatus` enum (Unknown/Healthy/Degraded/Unhealthy) +- `HealthProvider` interface +- `HealthReport` and `AggregatedHealth` structs +- `AggregateHealthService` with provider registry, concurrent fan-out, caching +- Per-provider panic recovery +- Temporary error detection (→ Degraded) +- Provider adapters: Simple, Static, Composite +- Health events: `HealthEvaluatedEvent`, `HealthStatusChangedEvent` +- Cache with TTL + force refresh context key + +## Key Interfaces + +```go +type HealthStatus int +const ( + StatusUnknown HealthStatus = iota + StatusHealthy + StatusDegraded + StatusUnhealthy +) + +type HealthProvider interface { + HealthCheck(ctx context.Context) ([]HealthReport, error) +} + +type HealthReport struct { + Module string + Component string + Status HealthStatus + Message string + CheckedAt time.Time + ObservedSince time.Time + Optional bool + Details map[string]any +} + +type AggregatedHealth struct { + Readiness HealthStatus + Health HealthStatus + Reports []HealthReport + GeneratedAt time.Time +} +``` + +## Architecture + +- Provider registry: `map[string]HealthProvider` behind `sync.RWMutex` +- Cache: single `AggregatedHealth` with TTL (default 250ms), invalidated on provider add/remove +- Force refresh: `context.WithValue(ctx, ForceHealthRefreshKey, true)` +- Concurrent collection: fan-out goroutines, per-provider panic recovery, channel-based results +- Aggregation: Readiness = worst non-optional, Health = worst all. Unknown → Unhealthy for aggregation +- Temporary errors (`interface{ Temporary() bool }`) → Degraded; other errors → Unhealthy + +## Files + +| Action | File | What | +|--------|------|------| +| Create | `health.go` | HealthStatus enum, HealthProvider, HealthReport, AggregatedHealth, provider adapters | +| Create | `health_service.go` | AggregateHealthService implementation | +| Modify | `observer.go` | Add EventTypeHealthEvaluated, EventTypeHealthStatusChanged | +| Create | `health_test.go` | Unit + concurrency + panic recovery tests | + +## Implementation Checklist + +- [ ] Define HealthStatus enum with String() and IsHealthy() +- [ ] Define HealthProvider interface +- [ ] Define HealthReport and AggregatedHealth structs +- [ ] Add health event constants to observer.go +- [ ] Implement AggregateHealthService with provider registry + RWMutex +- [ ] Implement concurrent fan-out collection with goroutines + channel +- [ ] Implement per-provider panic recovery (panic → Unhealthy with details) +- [ ] Implement aggregation logic (readiness = worst non-optional, health = worst all) +- [ ] Implement cache with TTL (250ms default) and force-refresh context key +- [ ] Implement cache invalidation on provider add/remove +- [ ] Implement NewSimpleHealthProvider adapter +- [ ] Implement NewStaticHealthProvider adapter +- [ ] Implement NewCompositeHealthProvider adapter +- [ ] Implement temporary error detection (Degraded vs Unhealthy) +- [ ] Emit HealthEvaluatedEvent after each aggregation +- [ ] Emit HealthStatusChangedEvent on status transitions only +- [ ] Write unit tests: single provider, multiple providers, optional vs required +- [ ] Write cache tests: hit, miss, invalidation, force refresh +- [ ] Write concurrency tests: parallel checks, registration during check +- [ ] Write panic recovery tests + +## Notes + +- 250ms cache TTL prevents health check storms while keeping results fresh. +- Panic recovery ensures one misbehaving provider cannot crash the health system. +- `ObservedSince` tracks when current status was first seen, enabling duration-based alerting. +- Optional providers affect Health but not Readiness. +- Module-specific providers (cache, database, eventbus, reverseproxy) are examples, not required for core. diff --git a/docs/plans/bdd-contract-testing.md b/docs/plans/bdd-contract-testing.md new file mode 100644 index 00000000..469e12bc --- /dev/null +++ b/docs/plans/bdd-contract-testing.md @@ -0,0 +1,80 @@ +# BDD/Contract Testing Framework — Revised Implementation Plan + +> Reset from CrisisTextLine/modular upstream (2026-03-09). This revision reflects what already exists. + +## Gap Analysis + +**Already exists (~65%):** +- Godog dependency (`go.mod`: `github.com/cucumber/godog v0.15.1`) +- 21 Gherkin feature files across core + modules +- 121 BDD test files across the codebase +- Core framework BDD: lifecycle, config, cycle detection, service registry, logger decorator +- Module BDD: auth, cache, database, eventbus, httpserver, httpclient, scheduler, reverseproxy, etc. +- Contract CLI: `modcli contract extract|compare|git-diff|tags` (`cmd/modcli/cmd/contract.go`, 636 lines) +- Contract types: `Contract`, `InterfaceContract`, `BreakingChange`, `ContractDiff` (`cmd/modcli/internal/contract/`) +- Contract extractor + differ with tests (1715 lines across 6 files) +- CI: `contract-check.yml` (241 lines) — extracts, compares, comments on PRs +- CI: `bdd-matrix.yml` (215 lines) — parallel module BDD, coverage merging +- BDD scripts: `run-module-bdd-parallel.sh`, `verify-bdd-tests.sh` + +**Must implement (depends on Dynamic Reload + Aggregate Health):** +- Reload contract feature file + step definitions (depends on Reloadable interface) +- Health contract feature file + step definitions (depends on HealthProvider interface) +- `ContractVerifier` interface for reload + health contracts +- Performance benchmark BDD (4 targets: bootstrap, lookup, reload, health) +- Concurrency stress test BDD scenarios + +## What to Build + +Since the BDD infrastructure and contract tooling are fully operational, the remaining work is: + +1. **Reload contract BDD** — write after Dynamic Reload is implemented +2. **Health contract BDD** — write after Aggregate Health is implemented +3. **ContractVerifier** — programmatic verification of reload/health behavioral contracts +4. **Performance benchmarks** — formalize the 4 targets as Go benchmarks + +## Files + +| Action | File | What | +|--------|------|------| +| Create | `features/reload_contract.feature` | Gherkin scenarios for Reloadable contract | +| Create | `features/health_contract.feature` | Gherkin scenarios for HealthProvider contract | +| Create | `reload_contract_bdd_test.go` | Step definitions for reload scenarios | +| Create | `health_contract_bdd_test.go` | Step definitions for health scenarios | +| Create | `contract_verifier.go` | ContractVerifier interface + implementations | +| Create | `contract_verifier_test.go` | Verifier tests | +| Create | `benchmark_test.go` | Performance benchmarks for 4 targets | + +## Implementation Checklist + +- [x] ~~Add godog dependency~~ (exists) +- [x] ~~Create features/ directory with core Gherkin files~~ (6 files exist) +- [x] ~~Write step definitions for lifecycle, config, cycle detection, service registry~~ (121 BDD tests) +- [x] ~~Implement ContractExtractor and ContractSnapshot~~ (contract package complete) +- [x] ~~Implement modcli contract extract/compare~~ (636-line CLI) +- [x] ~~Add CI contract comparison on PRs~~ (contract-check.yml) +- [ ] Create reload_contract.feature (after Dynamic Reload is implemented) +- [ ] Write reload contract step definitions +- [ ] Create health_contract.feature (after Aggregate Health is implemented) +- [ ] Write health contract step definitions +- [ ] Implement ContractVerifier for reload contracts +- [ ] Implement ContractVerifier for health contracts +- [ ] Write performance benchmarks (bootstrap <150ms, lookup <2us, reload <80ms, health <5ms) +- [ ] Write concurrency stress test scenarios + +## Performance Targets + +| Metric | Target (P50) | +|--------|-------------| +| Bootstrap (10 modules) | <150ms | +| Service lookup | <2us | +| Reload | <80ms | +| Health aggregation | <5ms | + +## Notes + +- Reload/health contract BDD depends on those features being implemented first. +- Performance targets are P50 on commodity hardware; CI tracks regressions, not absolutes. +- Constitution rules (no interface widening, additive only) are already enforced by contract-check.yml. +- Godog integrates with `testing.T` via `godog.TestSuite`. +- Feature files should be readable by non-engineers. diff --git a/docs/plans/dynamic-reload.md b/docs/plans/dynamic-reload.md new file mode 100644 index 00000000..1bee18a9 --- /dev/null +++ b/docs/plans/dynamic-reload.md @@ -0,0 +1,134 @@ +# Dynamic Reload Manager — Revised Implementation Plan + +> Reset from CrisisTextLine/modular upstream (2026-03-09). This revision reflects what already exists. + +## Gap Analysis + +**Already exists:** +- Observer pattern with CloudEvents (`observer.go`) — foundation for reload events +- Config field tracking (`config_field_tracking.go`) — `FieldPopulation`, `StructStateDiffer` +- Config providers with thread-safe variants (`config_provider.go`) — `ImmutableConfigProvider` (atomic.Value) +- Circuit breaker pattern in reverseproxy (`modules/reverseproxy/circuit_breaker.go`) — reference implementation +- `EventTypeConfigChanged` event constant +- Module interfaces: `Module`, `Configurable`, `Startable`, `Stoppable`, `DependencyAware` +- Builder pattern with `WithOnConfigLoaded()` option + +**Must implement:** +- `Reloadable` interface (add to `module.go`) +- `ConfigChange`, `ConfigDiff`, `FieldChange` types +- `ReloadTrigger` enum +- `ReloadOrchestrator` with request queue, CAS guard, circuit breaker +- Atomic reload with rollback semantics +- Reload lifecycle events (4 new event types) +- `RequestReload()` on Application interface +- `WithDynamicReload()` builder option +- Tests + +## Key Interfaces + +```go +type Reloadable interface { + Reload(ctx context.Context, changes []ConfigChange) error + CanReload() bool + ReloadTimeout() time.Duration +} + +type ConfigChange struct { + Section string + FieldPath string + OldValue any + NewValue any + Source string +} + +type ConfigDiff struct { + Changed map[string]FieldChange + Added map[string]FieldChange + Removed map[string]FieldChange + Timestamp time.Time + DiffID string +} + +type FieldChange struct { + OldValue any + NewValue any + FieldPath string + ChangeType ChangeType // Added, Modified, Removed + IsSensitive bool + ValidationResult error +} + +type ReloadTrigger int + +const ( + ReloadManual ReloadTrigger = iota + ReloadFileChange + ReloadAPIRequest + ReloadScheduled +) +``` + +## Architecture + +**ReloadOrchestrator** is the central coordinator: +- Module registry: `map[string]Reloadable` behind `sync.RWMutex` +- Request queue: buffered channel (capacity 100) of `ReloadRequest` +- Processing flag: `atomic.Bool` with CAS to ensure single-flight processing +- Background goroutine drains the request queue + +**Circuit breaker** with exponential backoff: +- Base delay: 2 seconds, max delay cap: 2 minutes +- Formula: `min(base * 2^(failures-1), cap)` +- Resets on successful reload, rejects while open + +**Atomic reload semantics**: +1. Compute `ConfigDiff` between old and new config +2. Filter modules by affected sections +3. Check `CanReload()` on each; skip those returning false +4. Apply changes with per-module timeout from `ReloadTimeout()` +5. On failure: roll back already-applied modules with reverse changes +6. Emit completion or failure event + +**Events** (add to observer.go): +- `EventTypeConfigReloadStarted` +- `EventTypeConfigReloadCompleted` +- `EventTypeConfigReloadFailed` +- `EventTypeConfigReloadNoop` + +## Files + +| Action | File | What | +|--------|------|------| +| Create | `reload.go` | ConfigChange, ConfigDiff, FieldChange, ReloadTrigger types + ConfigDiff methods | +| Modify | `module.go` | Add Reloadable interface | +| Create | `reload_orchestrator.go` | ReloadOrchestrator implementation | +| Modify | `observer.go` | Add 4 reload event type constants | +| Modify | `application.go` | Add RequestReload() method | +| Modify | `builder.go` | Add WithDynamicReload() option | +| Create | `reload_test.go` | Unit + concurrency tests | + +## Implementation Checklist + +- [ ] Define `Reloadable` interface in module.go +- [ ] Create reload.go with ConfigChange, ConfigDiff, FieldChange, ChangeType, ReloadTrigger +- [ ] Implement ConfigDiff methods: HasChanges, FilterByPrefix, RedactSensitiveFields, ChangeSummary +- [ ] Add 4 reload event constants to observer.go +- [ ] Implement ReloadOrchestrator with module registry + RWMutex +- [ ] Implement channel-based request queue (buffered, size 100) +- [ ] Implement atomic CAS processing guard +- [ ] Implement exponential backoff circuit breaker +- [ ] Implement atomic reload with rollback on failure +- [ ] Implement per-module timeout via context cancellation +- [ ] Emit reload lifecycle events via observer +- [ ] Add RequestReload() to Application interface + StdApplication +- [ ] Add WithDynamicReload() builder option +- [ ] Write unit tests: successful reload, partial failure + rollback, circuit breaker +- [ ] Write concurrency tests: concurrent requests, CAS contention + +## Notes + +- Modules returning `CanReload() == false` are skipped, not errors. +- Rollback applies reverse ConfigChange entries in reverse module order. +- Queue drops requests when full (capacity 100) and returns error. +- Circuit breaker state is internal to orchestrator; not exposed to modules. +- Sensitive field detection uses configurable field path patterns (e.g., `*password*`, `*secret*`). diff --git a/docs/plans/tenant-guard.md b/docs/plans/tenant-guard.md new file mode 100644 index 00000000..7f7fc915 --- /dev/null +++ b/docs/plans/tenant-guard.md @@ -0,0 +1,118 @@ +# TenantGuard Framework — Revised Implementation Plan + +> Reset from CrisisTextLine/modular upstream (2026-03-09). This revision reflects what already exists. + +## Gap Analysis + +**Already exists (~50% complete):** +- `TenantContext` with context propagation (`tenant.go:51-94`) +- `TenantService` interface + `StandardTenantService` implementation (`tenant.go`, `tenant_service.go`) +- `TenantAwareModule` interface with lifecycle hooks (`tenant.go:211-230`) +- `TenantConfigProvider` with RWMutex, isolation, immutability variants (`tenant_config_provider.go`) +- `TenantConfigLoader` + file-based implementation (`tenant_config_loader.go`, `tenant_config_file_loader.go`) +- `TenantAwareConfig` context-aware resolution (`tenant_aware_config.go`) +- `TenantAwareDecorator` application decorator (`decorator_tenant.go`) +- `TenantAffixedEnvFeeder` for tenant-specific env vars (`feeders/tenant_affixed_env.go`) +- `WithTenantAware()` builder option (`builder.go:163-169`) +- 8 tenant sentinel errors in `errors.go` +- ~28 test files covering tenant basics + +**Must implement:** +- `TenantGuard` interface + `StandardTenantGuard` implementation +- `TenantGuardMode` enum (Strict/Lenient/Disabled) +- `ViolationType` + `Severity` enums +- `TenantViolation` struct +- `TenantGuardConfig` with defaults +- Ring buffer for bounded violation history +- Whitelist support +- `WithTenantGuardMode()` + `WithTenantGuardModeConfig()` builder options +- 2 missing sentinel errors +- Violation event emission via observer +- Mode-specific tests + concurrency tests + +## Key Types (new) + +```go +type TenantGuardMode int +const ( + TenantGuardStrict TenantGuardMode = iota + TenantGuardLenient + TenantGuardDisabled +) + +type ViolationType int +const ( + CrossTenant ViolationType = iota + InvalidContext + MissingContext + Unauthorized +) + +type Severity int +const ( + SeverityLow Severity = iota + SeverityMedium + SeverityHigh + SeverityCritical +) + +type TenantViolation struct { + Type ViolationType + Severity Severity + TenantID string + TargetID string + Timestamp time.Time + Details string +} + +type TenantGuard interface { + GetMode() TenantGuardMode + ValidateAccess(ctx context.Context, violation TenantViolation) error + GetRecentViolations() []TenantViolation +} + +type TenantGuardConfig struct { + Mode TenantGuardMode + EnforceIsolation bool + AllowCrossTenant bool + ValidationTimeout time.Duration + Whitelist map[string][]string + MaxViolations int + LogViolations bool +} +``` + +## Files + +| Action | File | What | +|--------|------|------| +| Create | `tenant_guard.go` | TenantGuardMode, ViolationType, Severity enums, TenantViolation, TenantGuardConfig, TenantGuard interface, StandardTenantGuard with ring buffer | +| Modify | `errors.go` | Add ErrTenantContextMissing, ErrTenantIsolationViolation | +| Modify | `builder.go` | Add WithTenantGuardMode(), WithTenantGuardModeConfig() | +| Modify | `observer.go` | Add EventTypeTenantViolation constant | +| Create | `tenant_guard_test.go` | Unit + concurrency tests | + +## Implementation Checklist + +- [ ] Create tenant_guard.go with TenantGuardMode enum + String() +- [ ] Add ViolationType and Severity enums with String() methods +- [ ] Implement TenantViolation struct +- [ ] Implement TenantGuardConfig with defaults (MaxViolations: 1000, LogViolations: true) +- [ ] Implement StandardTenantGuard with RWMutex-protected ring buffer +- [ ] Implement ValidateAccess: strict returns error, lenient logs, disabled no-op +- [ ] Implement whitelist checking in ValidateAccess +- [ ] Implement GetRecentViolations with deep copy +- [ ] Add ErrTenantContextMissing and ErrTenantIsolationViolation to errors.go +- [ ] Add EventTypeTenantViolation to observer.go +- [ ] Add WithTenantGuardMode() and WithTenantGuardModeConfig() to builder.go +- [ ] Write tests: strict blocks, lenient logs, disabled skips +- [ ] Write tests: whitelist bypass, ring buffer FIFO eviction +- [ ] Write concurrency tests: parallel ValidateAccess, concurrent violations + +## Notes + +- Ring buffer bounded at MaxViolations (default 1000) entries; FIFO eviction when full. +- Strict mode returns ErrTenantIsolationViolation; lenient logs + returns nil. +- GetRecentViolations() deep-copies to prevent caller mutation. +- Whitelist allows explicit cross-tenant access for service accounts. +- Emit EventTypeTenantViolation via observer for external monitoring integration. diff --git a/drainable.go b/drainable.go new file mode 100644 index 00000000..d6794e23 --- /dev/null +++ b/drainable.go @@ -0,0 +1,15 @@ +package modular + +import ( + "context" + "time" +) + +// Drainable is an optional interface for modules that need a pre-stop drain phase. +// During shutdown, PreStop is called on all Drainable modules (reverse dependency order) +// before Stop is called on Stoppable modules. +type Drainable interface { + PreStop(ctx context.Context) error +} + +const defaultDrainTimeout = 15 * time.Second diff --git a/drainable_test.go b/drainable_test.go new file mode 100644 index 00000000..ac8d3586 --- /dev/null +++ b/drainable_test.go @@ -0,0 +1,71 @@ +package modular + +import ( + "context" + "testing" + "time" +) + +type drainableModule struct { + name string + preStopSeq *[]string + stopSeq *[]string +} + +func (m *drainableModule) Name() string { return m.name } +func (m *drainableModule) Init(app Application) error { return nil } +func (m *drainableModule) Start(ctx context.Context) error { return nil } +func (m *drainableModule) PreStop(ctx context.Context) error { + *m.preStopSeq = append(*m.preStopSeq, m.name) + return nil +} +func (m *drainableModule) Stop(ctx context.Context) error { + *m.stopSeq = append(*m.stopSeq, m.name) + return nil +} + +func TestDrainable_PreStopCalledBeforeStop(t *testing.T) { + preStops := make([]string, 0) + stops := make([]string, 0) + + mod := &drainableModule{name: "drainer", preStopSeq: &preStops, stopSeq: &stops} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(mod), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + + if len(preStops) != 1 || preStops[0] != "drainer" { + t.Errorf("expected PreStop called for drainer, got %v", preStops) + } + if len(stops) != 1 || stops[0] != "drainer" { + t.Errorf("expected Stop called for drainer, got %v", stops) + } +} + +func TestDrainable_WithDrainTimeout(t *testing.T) { + app, err := NewApplication( + WithLogger(nopLogger{}), + WithDrainTimeout(5*time.Second), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + stdApp := app.(*StdApplication) + if stdApp.drainTimeout != 5*time.Second { + t.Errorf("expected drain timeout 5s, got %v", stdApp.drainTimeout) + } +} diff --git a/enhanced_service_registry_test.go b/enhanced_service_registry_test.go index 6dbf0b50..70d04cfd 100644 --- a/enhanced_service_registry_test.go +++ b/enhanced_service_registry_test.go @@ -128,7 +128,7 @@ func TestEnhancedServiceRegistry_InterfaceDiscovery(t *testing.T) { registry.RegisterService("nonInterface", nonInterfaceService) // Discover by interface - interfaceType := reflect.TypeOf((*ServiceRegistryTestInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[ServiceRegistryTestInterface]() entries := registry.GetServicesByInterface(interfaceType) require.Len(t, entries, 2) diff --git a/errors.go b/errors.go index 8693c401..0b2c2528 100644 --- a/errors.go +++ b/errors.go @@ -82,6 +82,18 @@ var ( ErrMockTenantConfigsNotInitialized = errors.New("mock tenant configs not initialized") ErrConfigSectionNotFoundForTenant = errors.New("config section not found for tenant") + // Tenant guard errors + ErrTenantContextMissing = errors.New("tenant context is missing") + ErrTenantIsolationViolation = errors.New("tenant isolation violation") + + // Reload errors + ErrReloadCircuitBreakerOpen = errors.New("reload circuit breaker is open; backing off") + ErrReloadChannelFull = errors.New("reload request channel is full") + ErrReloadInProgress = errors.New("reload already in progress") + ErrReloadStopped = errors.New("reload orchestrator is stopped") + ErrReloadTimeout = errors.New("reload timed out waiting for module") + ErrDynamicReloadNotEnabled = errors.New("dynamic reload not enabled") + // Observer/Event emission errors ErrNoSubjectForEventEmission = errors.New("no subject available for event emission") diff --git a/event_emission_fix_test.go b/event_emission_fix_test.go index 58948e09..1feceb9c 100644 --- a/event_emission_fix_test.go +++ b/event_emission_fix_test.go @@ -62,7 +62,7 @@ func TestModuleEventEmissionWithoutSubject(t *testing.T) { } // testModuleNilSubjectHandling is a helper function that tests nil subject handling for a specific module -func testModuleNilSubjectHandling(t *testing.T, modulePath, moduleName string) { +func testModuleNilSubjectHandling(t *testing.T, _, moduleName string) { // Create a mock application for testing app := &mockApplicationForNilSubjectTest{} @@ -85,7 +85,7 @@ func testModuleNilSubjectHandling(t *testing.T, modulePath, moduleName string) { // Test the emitEvent helper pattern - this should not panic and should handle nil subject gracefully // We can't call the actual module's emitEvent helper directly since it's private, // but we can verify the pattern works by testing that no panic occurs - testModule.testEmitEventHelper(context.Background(), "test.event.type", map[string]interface{}{ + testModule.testEmitEventHelper(context.Background(), "test.event.type", map[string]any{ "test_key": "test_value", }) } @@ -135,7 +135,7 @@ func (t *testObservableModuleForNilSubject) EmitEvent(ctx context.Context, event } // testEmitEventHelper simulates the pattern used by modules' emitEvent helper methods -func (t *testObservableModuleForNilSubject) testEmitEventHelper(ctx context.Context, eventType string, data map[string]interface{}) { +func (t *testObservableModuleForNilSubject) testEmitEventHelper(ctx context.Context, eventType string, data map[string]any) { // This simulates the pattern used in modules - check for nil subject first if t.subject == nil { return // Should return silently without error @@ -162,13 +162,13 @@ type mockTestLogger struct { lastDebugMessage string } -func (l *mockTestLogger) Debug(msg string, args ...interface{}) { +func (l *mockTestLogger) Debug(msg string, args ...any) { l.lastDebugMessage = msg } -func (l *mockTestLogger) Info(msg string, args ...interface{}) {} -func (l *mockTestLogger) Warn(msg string, args ...interface{}) {} -func (l *mockTestLogger) Error(msg string, args ...interface{}) {} +func (l *mockTestLogger) Info(msg string, args ...any) {} +func (l *mockTestLogger) Warn(msg string, args ...any) {} +func (l *mockTestLogger) Error(msg string, args ...any) {} type mockApplicationForNilSubjectTest struct{} diff --git a/examples/advanced-logging/go.mod b/examples/advanced-logging/go.mod index a0ed63b9..85eb8e8e 100644 --- a/examples/advanced-logging/go.mod +++ b/examples/advanced-logging/go.mod @@ -5,11 +5,11 @@ go 1.25 toolchain go1.25.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/chimux v1.1.0 - github.com/CrisisTextLine/modular/modules/httpclient v0.1.0 - github.com/CrisisTextLine/modular/modules/httpserver v0.1.1 - github.com/CrisisTextLine/modular/modules/reverseproxy v1.1.0 + github.com/GoCodeAlone/modular v1.11.11 + github.com/GoCodeAlone/modular/modules/chimux v1.1.0 + github.com/GoCodeAlone/modular/modules/httpclient v0.1.0 + github.com/GoCodeAlone/modular/modules/httpserver v0.1.1 + github.com/GoCodeAlone/modular/modules/reverseproxy v1.1.0 ) require ( @@ -27,12 +27,12 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -replace github.com/CrisisTextLine/modular/modules/chimux => ../../modules/chimux +replace github.com/GoCodeAlone/modular/modules/chimux => ../../modules/chimux -replace github.com/CrisisTextLine/modular/modules/httpclient => ../../modules/httpclient +replace github.com/GoCodeAlone/modular/modules/httpclient => ../../modules/httpclient -replace github.com/CrisisTextLine/modular/modules/httpserver => ../../modules/httpserver +replace github.com/GoCodeAlone/modular/modules/httpserver => ../../modules/httpserver -replace github.com/CrisisTextLine/modular/modules/reverseproxy => ../../modules/reverseproxy +replace github.com/GoCodeAlone/modular/modules/reverseproxy => ../../modules/reverseproxy diff --git a/examples/advanced-logging/main.go b/examples/advanced-logging/main.go index 6cee0b71..eb36cd9c 100644 --- a/examples/advanced-logging/main.go +++ b/examples/advanced-logging/main.go @@ -7,12 +7,12 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/chimux" - "github.com/CrisisTextLine/modular/modules/httpclient" - "github.com/CrisisTextLine/modular/modules/httpserver" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/chimux" + "github.com/GoCodeAlone/modular/modules/httpclient" + "github.com/GoCodeAlone/modular/modules/httpserver" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) type AppConfig struct { diff --git a/examples/base-config-example/go.mod b/examples/base-config-example/go.mod index adf78c88..8e7510ef 100644 --- a/examples/base-config-example/go.mod +++ b/examples/base-config-example/go.mod @@ -1,8 +1,8 @@ -module github.com/CrisisTextLine/modular/examples/base-config-example +module github.com/GoCodeAlone/modular/examples/base-config-example go 1.25 -require github.com/CrisisTextLine/modular v1.11.9 +require github.com/GoCodeAlone/modular v1.11.9 require ( github.com/BurntSushi/toml v1.6.0 // indirect @@ -17,4 +17,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ diff --git a/examples/base-config-example/main.go b/examples/base-config-example/main.go index 2cdc4ae9..85bd6d0a 100644 --- a/examples/base-config-example/main.go +++ b/examples/base-config-example/main.go @@ -6,7 +6,7 @@ import ( "os" "strings" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // AppConfig represents our application configuration diff --git a/examples/basic-app/api/api.go b/examples/basic-app/api/api.go index eb94315f..bdc2e791 100644 --- a/examples/basic-app/api/api.go +++ b/examples/basic-app/api/api.go @@ -5,7 +5,7 @@ import ( "net/http" "reflect" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/go-chi/chi/v5" ) diff --git a/examples/basic-app/go.mod b/examples/basic-app/go.mod index da31dbfa..0b9d5fac 100644 --- a/examples/basic-app/go.mod +++ b/examples/basic-app/go.mod @@ -2,10 +2,10 @@ module basic-app go 1.25 -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ require ( - github.com/CrisisTextLine/modular v1.11.9 + github.com/GoCodeAlone/modular v1.11.9 github.com/go-chi/chi/v5 v5.2.2 ) diff --git a/examples/basic-app/main.go b/examples/basic-app/main.go index 0efae3af..7ca8013b 100644 --- a/examples/basic-app/main.go +++ b/examples/basic-app/main.go @@ -8,8 +8,8 @@ import ( "log/slog" "os" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" ) func main() { diff --git a/examples/basic-app/router/router.go b/examples/basic-app/router/router.go index 64b24d75..a694d4ea 100644 --- a/examples/basic-app/router/router.go +++ b/examples/basic-app/router/router.go @@ -5,7 +5,7 @@ import ( "fmt" "net/http" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) diff --git a/examples/basic-app/webserver/webserver.go b/examples/basic-app/webserver/webserver.go index 5f013b54..d43d2a77 100644 --- a/examples/basic-app/webserver/webserver.go +++ b/examples/basic-app/webserver/webserver.go @@ -9,7 +9,7 @@ import ( "reflect" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) const configSection = "webserver" diff --git a/examples/feature-flag-proxy/go.mod b/examples/feature-flag-proxy/go.mod index ab4df210..ce6f505d 100644 --- a/examples/feature-flag-proxy/go.mod +++ b/examples/feature-flag-proxy/go.mod @@ -5,10 +5,10 @@ go 1.25 toolchain go1.25.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/chimux v1.1.0 - github.com/CrisisTextLine/modular/modules/httpserver v0.1.1 - github.com/CrisisTextLine/modular/modules/reverseproxy v1.1.2 + github.com/GoCodeAlone/modular v1.11.11 + github.com/GoCodeAlone/modular/modules/chimux v1.1.0 + github.com/GoCodeAlone/modular/modules/httpserver v0.1.1 + github.com/GoCodeAlone/modular/modules/reverseproxy v1.1.2 ) require ( @@ -26,10 +26,10 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../.. +replace github.com/GoCodeAlone/modular => ../.. -replace github.com/CrisisTextLine/modular/modules/chimux => ../../modules/chimux +replace github.com/GoCodeAlone/modular/modules/chimux => ../../modules/chimux -replace github.com/CrisisTextLine/modular/modules/httpserver => ../../modules/httpserver +replace github.com/GoCodeAlone/modular/modules/httpserver => ../../modules/httpserver -replace github.com/CrisisTextLine/modular/modules/reverseproxy => ../../modules/reverseproxy +replace github.com/GoCodeAlone/modular/modules/reverseproxy => ../../modules/reverseproxy diff --git a/examples/feature-flag-proxy/main.go b/examples/feature-flag-proxy/main.go index 42921d3d..9a8bd56d 100644 --- a/examples/feature-flag-proxy/main.go +++ b/examples/feature-flag-proxy/main.go @@ -7,11 +7,11 @@ import ( "os" "regexp" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/chimux" - "github.com/CrisisTextLine/modular/modules/httpserver" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/chimux" + "github.com/GoCodeAlone/modular/modules/httpserver" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) type AppConfig struct { diff --git a/examples/feature-flag-proxy/main_test.go b/examples/feature-flag-proxy/main_test.go index e747168d..ee92b606 100644 --- a/examples/feature-flag-proxy/main_test.go +++ b/examples/feature-flag-proxy/main_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) // TestFeatureFlagEvaluatorIntegration tests the integration between modules diff --git a/examples/health-aware-reverse-proxy/go.mod b/examples/health-aware-reverse-proxy/go.mod index dd81b896..e36ec1bd 100644 --- a/examples/health-aware-reverse-proxy/go.mod +++ b/examples/health-aware-reverse-proxy/go.mod @@ -5,10 +5,10 @@ go 1.25 toolchain go1.25.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/chimux v0.0.0-00010101000000-000000000000 - github.com/CrisisTextLine/modular/modules/httpserver v0.0.0-00010101000000-000000000000 - github.com/CrisisTextLine/modular/modules/reverseproxy v0.0.0-00010101000000-000000000000 + github.com/GoCodeAlone/modular v1.11.11 + github.com/GoCodeAlone/modular/modules/chimux v0.0.0-00010101000000-000000000000 + github.com/GoCodeAlone/modular/modules/httpserver v0.0.0-00010101000000-000000000000 + github.com/GoCodeAlone/modular/modules/reverseproxy v0.0.0-00010101000000-000000000000 ) require ( @@ -26,12 +26,12 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../.. +replace github.com/GoCodeAlone/modular => ../.. -replace github.com/CrisisTextLine/modular/modules/reverseproxy => ../../modules/reverseproxy +replace github.com/GoCodeAlone/modular/modules/reverseproxy => ../../modules/reverseproxy -replace github.com/CrisisTextLine/modular/modules/chimux => ../../modules/chimux +replace github.com/GoCodeAlone/modular/modules/chimux => ../../modules/chimux -replace github.com/CrisisTextLine/modular/modules/httpserver => ../../modules/httpserver +replace github.com/GoCodeAlone/modular/modules/httpserver => ../../modules/httpserver -replace github.com/CrisisTextLine/modular/feeders => ../../feeders +replace github.com/GoCodeAlone/modular/feeders => ../../feeders diff --git a/examples/health-aware-reverse-proxy/main.go b/examples/health-aware-reverse-proxy/main.go index b5066822..18c0ae8b 100644 --- a/examples/health-aware-reverse-proxy/main.go +++ b/examples/health-aware-reverse-proxy/main.go @@ -9,11 +9,11 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/chimux" - "github.com/CrisisTextLine/modular/modules/httpserver" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/chimux" + "github.com/GoCodeAlone/modular/modules/httpserver" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) type AppConfig struct { diff --git a/examples/http-client/go.mod b/examples/http-client/go.mod index 939cb4d7..02273bf3 100644 --- a/examples/http-client/go.mod +++ b/examples/http-client/go.mod @@ -5,11 +5,11 @@ go 1.25 toolchain go1.25.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/chimux v1.1.0 - github.com/CrisisTextLine/modular/modules/httpclient v0.1.0 - github.com/CrisisTextLine/modular/modules/httpserver v0.1.1 - github.com/CrisisTextLine/modular/modules/reverseproxy v1.1.0 + github.com/GoCodeAlone/modular v1.11.11 + github.com/GoCodeAlone/modular/modules/chimux v1.1.0 + github.com/GoCodeAlone/modular/modules/httpclient v0.1.0 + github.com/GoCodeAlone/modular/modules/httpserver v0.1.1 + github.com/GoCodeAlone/modular/modules/reverseproxy v1.1.0 github.com/stretchr/testify v1.11.1 ) @@ -30,12 +30,12 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -replace github.com/CrisisTextLine/modular/modules/chimux => ../../modules/chimux +replace github.com/GoCodeAlone/modular/modules/chimux => ../../modules/chimux -replace github.com/CrisisTextLine/modular/modules/httpclient => ../../modules/httpclient +replace github.com/GoCodeAlone/modular/modules/httpclient => ../../modules/httpclient -replace github.com/CrisisTextLine/modular/modules/httpserver => ../../modules/httpserver +replace github.com/GoCodeAlone/modular/modules/httpserver => ../../modules/httpserver -replace github.com/CrisisTextLine/modular/modules/reverseproxy => ../../modules/reverseproxy +replace github.com/GoCodeAlone/modular/modules/reverseproxy => ../../modules/reverseproxy diff --git a/examples/http-client/gzip_logging_integration_test.go b/examples/http-client/gzip_logging_integration_test.go index a5fd346e..ea3b2e97 100644 --- a/examples/http-client/gzip_logging_integration_test.go +++ b/examples/http-client/gzip_logging_integration_test.go @@ -12,8 +12,8 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/httpclient" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/httpclient" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/examples/http-client/main.go b/examples/http-client/main.go index 488d183d..4de12df6 100644 --- a/examples/http-client/main.go +++ b/examples/http-client/main.go @@ -4,12 +4,12 @@ import ( "log/slog" "os" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/chimux" - "github.com/CrisisTextLine/modular/modules/httpclient" - "github.com/CrisisTextLine/modular/modules/httpserver" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/chimux" + "github.com/GoCodeAlone/modular/modules/httpclient" + "github.com/GoCodeAlone/modular/modules/httpserver" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) type AppConfig struct { diff --git a/examples/instance-aware-db/go.mod b/examples/instance-aware-db/go.mod index 88d445c7..4d94e548 100644 --- a/examples/instance-aware-db/go.mod +++ b/examples/instance-aware-db/go.mod @@ -2,20 +2,20 @@ module instance-aware-db go 1.25 -replace github.com/CrisisTextLine/modular => ../.. +replace github.com/GoCodeAlone/modular => ../.. -replace github.com/CrisisTextLine/modular/modules/database => ../../modules/database +replace github.com/GoCodeAlone/modular/modules/database => ../../modules/database require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/database v1.4.0 + github.com/GoCodeAlone/modular v1.11.11 + github.com/GoCodeAlone/modular/modules/database v1.4.0 github.com/mattn/go-sqlite3 v1.14.32 ) require ( filippo.io/edwards25519 v1.1.1 // indirect github.com/BurntSushi/toml v1.6.0 // indirect - github.com/CrisisTextLine/modular/modules/database/v2 v2.2.0 // indirect + github.com/GoCodeAlone/modular/modules/database/v2 v2.2.0 // indirect github.com/aws/aws-sdk-go-v2 v1.38.3 // indirect github.com/aws/aws-sdk-go-v2/config v1.31.0 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.18.10 // indirect diff --git a/examples/instance-aware-db/go.sum b/examples/instance-aware-db/go.sum index bb8f738b..68f4e106 100644 --- a/examples/instance-aware-db/go.sum +++ b/examples/instance-aware-db/go.sum @@ -5,8 +5,8 @@ filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular/modules/database/v2 v2.2.0 h1:/U3RgMzuVQDZOmVW2dZsrT8eJUiQB2QHo2TLW9EPj9Y= -github.com/CrisisTextLine/modular/modules/database/v2 v2.2.0/go.mod h1:sDD70d2nKzNfJGUOGBmzecO+4nxRA641M5UDdblBNK4= +github.com/GoCodeAlone/modular/modules/database/v2 v2.2.0 h1:/U3RgMzuVQDZOmVW2dZsrT8eJUiQB2QHo2TLW9EPj9Y= +github.com/GoCodeAlone/modular/modules/database/v2 v2.2.0/go.mod h1:sDD70d2nKzNfJGUOGBmzecO+4nxRA641M5UDdblBNK4= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= diff --git a/examples/instance-aware-db/main.go b/examples/instance-aware-db/main.go index d0a611f3..1fad6112 100644 --- a/examples/instance-aware-db/main.go +++ b/examples/instance-aware-db/main.go @@ -6,9 +6,9 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/database" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/database" // Import SQLite driver _ "github.com/mattn/go-sqlite3" diff --git a/examples/logger-reconfiguration/go.mod b/examples/logger-reconfiguration/go.mod index 39e7b3ca..f1764e34 100644 --- a/examples/logger-reconfiguration/go.mod +++ b/examples/logger-reconfiguration/go.mod @@ -2,9 +2,9 @@ module logger-reconfiguration go 1.25 -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -require github.com/CrisisTextLine/modular v1.11.9-00010101000000-000000000000 +require github.com/GoCodeAlone/modular v1.11.9-00010101000000-000000000000 require ( github.com/BurntSushi/toml v1.6.0 // indirect diff --git a/examples/logger-reconfiguration/main.go b/examples/logger-reconfiguration/main.go index 4f1525d1..43c1b421 100644 --- a/examples/logger-reconfiguration/main.go +++ b/examples/logger-reconfiguration/main.go @@ -5,8 +5,8 @@ import ( "log/slog" "os" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" ) func main() { diff --git a/examples/logmasker-example/go.mod b/examples/logmasker-example/go.mod index 8d79d2ce..e8000006 100644 --- a/examples/logmasker-example/go.mod +++ b/examples/logmasker-example/go.mod @@ -3,8 +3,8 @@ module logmasker-example go 1.25 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/logmasker v0.0.0 + github.com/GoCodeAlone/modular v1.11.11 + github.com/GoCodeAlone/modular/modules/logmasker v0.0.0 ) require ( @@ -20,6 +20,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -replace github.com/CrisisTextLine/modular/modules/logmasker => ../../modules/logmasker +replace github.com/GoCodeAlone/modular/modules/logmasker => ../../modules/logmasker diff --git a/examples/logmasker-example/main.go b/examples/logmasker-example/main.go index ad49da9b..ff4cce56 100644 --- a/examples/logmasker-example/main.go +++ b/examples/logmasker-example/main.go @@ -3,8 +3,8 @@ package main import ( "log" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/logmasker" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/logmasker" ) // SimpleLogger implements modular.Logger for demonstration diff --git a/examples/multi-engine-eventbus/go.mod b/examples/multi-engine-eventbus/go.mod index 4824f21d..ca572741 100644 --- a/examples/multi-engine-eventbus/go.mod +++ b/examples/multi-engine-eventbus/go.mod @@ -5,8 +5,8 @@ go 1.25 toolchain go1.25.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/eventbus v1.7.0 + github.com/GoCodeAlone/modular v1.11.11 + github.com/GoCodeAlone/modular/modules/eventbus v1.7.0 ) require ( @@ -71,6 +71,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -replace github.com/CrisisTextLine/modular/modules/eventbus => ../../modules/eventbus +replace github.com/GoCodeAlone/modular/modules/eventbus => ../../modules/eventbus diff --git a/examples/multi-engine-eventbus/main.go b/examples/multi-engine-eventbus/main.go index a5f88563..18bfae90 100644 --- a/examples/multi-engine-eventbus/main.go +++ b/examples/multi-engine-eventbus/main.go @@ -7,8 +7,8 @@ import ( "net" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/eventbus" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/eventbus" ) // testLogger is a simple logger for the example diff --git a/examples/multi-tenant-app/go.mod b/examples/multi-tenant-app/go.mod index 2e91abc6..e03d7f80 100644 --- a/examples/multi-tenant-app/go.mod +++ b/examples/multi-tenant-app/go.mod @@ -2,9 +2,9 @@ module multi-tenant-app go 1.25 -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -require github.com/CrisisTextLine/modular v1.11.9 +require github.com/GoCodeAlone/modular v1.11.9 require ( github.com/BurntSushi/toml v1.6.0 // indirect diff --git a/examples/multi-tenant-app/main.go b/examples/multi-tenant-app/main.go index d8c51598..44a6dbd7 100644 --- a/examples/multi-tenant-app/main.go +++ b/examples/multi-tenant-app/main.go @@ -6,8 +6,8 @@ import ( "os" "regexp" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" ) func main() { diff --git a/examples/multi-tenant-app/modules.go b/examples/multi-tenant-app/modules.go index 4893ff77..4c53d75f 100644 --- a/examples/multi-tenant-app/modules.go +++ b/examples/multi-tenant-app/modules.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Static error variables for err113 compliance diff --git a/examples/nats-eventbus/go.mod b/examples/nats-eventbus/go.mod index 006ff862..0ed7d322 100644 --- a/examples/nats-eventbus/go.mod +++ b/examples/nats-eventbus/go.mod @@ -4,13 +4,13 @@ go 1.25 toolchain go1.25.0 -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -replace github.com/CrisisTextLine/modular/modules/eventbus => ../../modules/eventbus +replace github.com/GoCodeAlone/modular/modules/eventbus => ../../modules/eventbus require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/eventbus v1.7.0 + github.com/GoCodeAlone/modular v1.11.11 + github.com/GoCodeAlone/modular/modules/eventbus v1.7.0 ) require ( diff --git a/examples/nats-eventbus/main.go b/examples/nats-eventbus/main.go index 09d0ae1c..470e2560 100644 --- a/examples/nats-eventbus/main.go +++ b/examples/nats-eventbus/main.go @@ -12,8 +12,8 @@ import ( "syscall" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/eventbus" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/eventbus" ) // testLogger is a simple logger for the example diff --git a/examples/observer-demo/go.mod b/examples/observer-demo/go.mod index 008cb054..41825f49 100644 --- a/examples/observer-demo/go.mod +++ b/examples/observer-demo/go.mod @@ -4,13 +4,13 @@ go 1.25 toolchain go1.25.0 -replace github.com/CrisisTextLine/modular => ../.. +replace github.com/GoCodeAlone/modular => ../.. -replace github.com/CrisisTextLine/modular/modules/eventlogger => ../../modules/eventlogger +replace github.com/GoCodeAlone/modular/modules/eventlogger => ../../modules/eventlogger require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/eventlogger v0.0.0-00010101000000-000000000000 + github.com/GoCodeAlone/modular v1.11.11 + github.com/GoCodeAlone/modular/modules/eventlogger v0.0.0-00010101000000-000000000000 github.com/cloudevents/sdk-go/v2 v2.16.2 ) diff --git a/examples/observer-demo/main.go b/examples/observer-demo/main.go index 371ff4af..3c6bb127 100644 --- a/examples/observer-demo/main.go +++ b/examples/observer-demo/main.go @@ -7,8 +7,8 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/eventlogger" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/eventlogger" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/examples/observer-pattern/audit_module.go b/examples/observer-pattern/audit_module.go index c690058c..b1467e01 100644 --- a/examples/observer-pattern/audit_module.go +++ b/examples/observer-pattern/audit_module.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/examples/observer-pattern/cloudevents_module.go b/examples/observer-pattern/cloudevents_module.go index b91d2a8d..3d6a5eb6 100644 --- a/examples/observer-pattern/cloudevents_module.go +++ b/examples/observer-pattern/cloudevents_module.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/examples/observer-pattern/go.mod b/examples/observer-pattern/go.mod index 2ebb342d..1a1b51fc 100644 --- a/examples/observer-pattern/go.mod +++ b/examples/observer-pattern/go.mod @@ -5,8 +5,8 @@ go 1.25 toolchain go1.25.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/eventlogger v0.0.0-00010101000000-000000000000 + github.com/GoCodeAlone/modular v1.11.11 + github.com/GoCodeAlone/modular/modules/eventlogger v0.0.0-00010101000000-000000000000 github.com/cloudevents/sdk-go/v2 v2.16.2 ) @@ -22,6 +22,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../.. +replace github.com/GoCodeAlone/modular => ../.. -replace github.com/CrisisTextLine/modular/modules/eventlogger => ../../modules/eventlogger +replace github.com/GoCodeAlone/modular/modules/eventlogger => ../../modules/eventlogger diff --git a/examples/observer-pattern/main.go b/examples/observer-pattern/main.go index 699223ee..56c5f014 100644 --- a/examples/observer-pattern/main.go +++ b/examples/observer-pattern/main.go @@ -7,9 +7,9 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/eventlogger" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/eventlogger" ) func main() { diff --git a/examples/observer-pattern/notification_module.go b/examples/observer-pattern/notification_module.go index ad188f0e..a8fbcc71 100644 --- a/examples/observer-pattern/notification_module.go +++ b/examples/observer-pattern/notification_module.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/examples/observer-pattern/user_module.go b/examples/observer-pattern/user_module.go index 1ea2857a..877b2ccf 100644 --- a/examples/observer-pattern/user_module.go +++ b/examples/observer-pattern/user_module.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/examples/reverse-proxy/config.yaml b/examples/reverse-proxy/config.yaml index 785805dd..c8ac8d30 100644 --- a/examples/reverse-proxy/config.yaml +++ b/examples/reverse-proxy/config.yaml @@ -28,6 +28,14 @@ reverseproxy: # Custom Transformer Backends profile-backend: "http://localhost:9013" analytics-backend: "http://localhost:9014" + + # Pipeline Strategy Backends + conversations-backend: "http://localhost:9015" + followup-backend: "http://localhost:9016" + + # Fan-Out-Merge Strategy Backends + tickets-backend: "http://localhost:9017" + assignments-backend: "http://localhost:9018" default_backend: "global-default" tenant_id_header: "X-Tenant-ID" @@ -100,6 +108,29 @@ reverseproxy: - "analytics-backend" strategy: "merge" # Strategy is set, but transformer overrides merge behavior + # STRATEGY 4: PIPELINE + # Executes backends sequentially where each stage's response informs the next request. + # Use case: A list page showing queued conversations. Backend A returns conversation + # details, those IDs are fed into Backend B to fetch follow-up information, + # and the responses are merged into a unified view. + "/api/composite/pipeline": + pattern: "/api/composite/pipeline" + backends: + - "conversations-backend" + - "followup-backend" + strategy: "pipeline" + + # STRATEGY 5: FAN-OUT-MERGE + # Executes all backends in parallel, then merges responses by matching IDs. + # Use case: A ticket dashboard where tickets come from one service and + # assignment/priority data comes from another. The merger correlates by ticket ID. + "/api/composite/fanout-merge": + pattern: "/api/composite/fanout-merge" + backends: + - "tickets-backend" + - "assignments-backend" + strategy: "fan-out-merge" + # ChiMux router configuration chimux: basepath: "" diff --git a/examples/reverse-proxy/go.mod b/examples/reverse-proxy/go.mod index 5a168cd8..0600e190 100644 --- a/examples/reverse-proxy/go.mod +++ b/examples/reverse-proxy/go.mod @@ -5,10 +5,10 @@ go 1.25 toolchain go1.25.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/chimux v1.1.0 - github.com/CrisisTextLine/modular/modules/httpserver v0.1.1 - github.com/CrisisTextLine/modular/modules/reverseproxy v1.1.0 + github.com/GoCodeAlone/modular v1.11.11 + github.com/GoCodeAlone/modular/modules/chimux v1.1.0 + github.com/GoCodeAlone/modular/modules/httpserver v0.1.1 + github.com/GoCodeAlone/modular/modules/reverseproxy v1.1.0 ) require ( @@ -26,10 +26,10 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -replace github.com/CrisisTextLine/modular/modules/chimux => ../../modules/chimux +replace github.com/GoCodeAlone/modular/modules/chimux => ../../modules/chimux -replace github.com/CrisisTextLine/modular/modules/httpserver => ../../modules/httpserver +replace github.com/GoCodeAlone/modular/modules/httpserver => ../../modules/httpserver -replace github.com/CrisisTextLine/modular/modules/reverseproxy => ../../modules/reverseproxy +replace github.com/GoCodeAlone/modular/modules/reverseproxy => ../../modules/reverseproxy diff --git a/examples/reverse-proxy/main.go b/examples/reverse-proxy/main.go index 6285b495..3ac49762 100644 --- a/examples/reverse-proxy/main.go +++ b/examples/reverse-proxy/main.go @@ -2,19 +2,21 @@ package main import ( "bytes" + "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "os" + "strings" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/chimux" - "github.com/CrisisTextLine/modular/modules/httpserver" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/chimux" + "github.com/GoCodeAlone/modular/modules/httpserver" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) type AppConfig struct { @@ -168,6 +170,118 @@ func main() { return resp, nil }) + // PIPELINE STRATEGY EXAMPLE: + // This demonstrates chained backend requests where backend B's request is constructed + // using data from backend A's response. This is the map/reduce pattern. + // + // Use case: A list page shows queued conversations. Backend A returns conversation details, + // then those conversation IDs are fed into Backend B to fetch follow-up information. + // The responses are then merged to produce a unified view. + proxyModule.SetPipelineConfig("/api/composite/pipeline", reverseproxy.PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + if nextBackendID == "followup-backend" { + // Extract conversation IDs from the conversations backend response + var convResp struct { + Conversations []struct { + ID string `json:"id"` + } `json:"conversations"` + } + if body, ok := previousResponses["conversations-backend"]; ok { + if err := json.Unmarshal(body, &convResp); err != nil { + return nil, fmt.Errorf("failed to parse conversations: %w", err) + } + } + + // Build the follow-up request with those IDs + ids := make([]string, 0, len(convResp.Conversations)) + for _, c := range convResp.Conversations { + ids = append(ids, c.ID) + } + idsParam := "" + for i, id := range ids { + if i > 0 { + idsParam += "," + } + idsParam += id + } + + url := "http://localhost:9016/followups?ids=" + idsParam + return http.NewRequestWithContext(ctx, "GET", url, nil) + } + return nil, fmt.Errorf("unknown pipeline backend: %s", nextBackendID) + }, + ResponseMerger: func(ctx context.Context, originalReq *http.Request, allResponses map[string][]byte) (*http.Response, error) { + // Parse conversations + var convResp struct { + Conversations []map[string]interface{} `json:"conversations"` + } + if body, ok := allResponses["conversations-backend"]; ok { + json.Unmarshal(body, &convResp) + } + + // Parse follow-ups + var fuResp struct { + FollowUps map[string]interface{} `json:"follow_ups"` + } + if body, ok := allResponses["followup-backend"]; ok { + json.Unmarshal(body, &fuResp) + } + + // Merge follow-up data into each conversation + for i, conv := range convResp.Conversations { + if id, ok := conv["id"].(string); ok { + if fu, exists := fuResp.FollowUps[id]; exists { + convResp.Conversations[i]["follow_up"] = fu + } + } + } + + return reverseproxy.MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "conversations": convResp.Conversations, + "strategy": "pipeline", + }) + }, + }) + + // FAN-OUT-MERGE STRATEGY EXAMPLE: + // This demonstrates parallel requests to multiple backends with custom ID-based + // response merging. Both backends are called simultaneously, then their responses + // are correlated by matching IDs. + // + // Use case: Show a ticket dashboard where tickets come from one service and + // priority/assignment data comes from another. The merger matches by ticket ID. + proxyModule.SetFanOutMerger("/api/composite/fanout-merge", func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + // Parse tickets from the tickets backend + var ticketsResp struct { + Tickets []map[string]interface{} `json:"tickets"` + } + if body, ok := responses["tickets-backend"]; ok { + json.Unmarshal(body, &ticketsResp) + } + + // Parse assignments from the assignments backend + var assignResp struct { + Assignments map[string]interface{} `json:"assignments"` + } + if body, ok := responses["assignments-backend"]; ok { + json.Unmarshal(body, &assignResp) + } + + // Merge assignments into tickets by ID + for i, ticket := range ticketsResp.Tickets { + if id, ok := ticket["id"].(string); ok { + if assignment, exists := assignResp.Assignments[id]; exists { + ticketsResp.Tickets[i]["assignment"] = assignment + } + } + } + + return reverseproxy.MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "tickets": ticketsResp.Tickets, + "strategy": "fan-out-merge", + }) + }) + app.RegisterModule(proxyModule) app.RegisterModule(httpserver.NewHTTPServerModule()) @@ -403,4 +517,89 @@ func startMockBackends() { fmt.Printf("Backend server error on :9014: %v\n", err) } }() + + // ======================================== + // Backends for PIPELINE strategy demonstration + // ======================================== + + // Conversations backend (port 9015) - Returns queued conversations + go func() { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"conversations":[{"id":"conv-1","status":"queued","counselor":"Alice","created_at":"2024-01-01T10:00:00Z"},{"id":"conv-2","status":"queued","counselor":"Bob","created_at":"2024-01-01T10:05:00Z"},{"id":"conv-3","status":"active","counselor":"Carol","created_at":"2024-01-01T10:10:00Z"}]}`) + }) + fmt.Println("Starting conversations-backend (pipeline demo) on :9015") + if err := http.ListenAndServe(":9015", mux); err != nil { //nolint:gosec + fmt.Printf("Backend server error on :9015: %v\n", err) + } + }() + + // Follow-up backend (port 9016) - Returns follow-up details for given conversation IDs + go func() { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + idsParam := r.URL.Query().Get("ids") + followUps := make(map[string]interface{}) + if idsParam != "" { + for _, id := range strings.Split(idsParam, ",") { + switch id { + case "conv-1": + followUps[id] = map[string]interface{}{ + "is_follow_up": true, + "original_conv_id": "conv-50", + "follow_up_count": 2, + } + case "conv-3": + followUps[id] = map[string]interface{}{ + "is_follow_up": true, + "original_conv_id": "conv-90", + "follow_up_count": 1, + } + } + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + resp, _ := json.Marshal(map[string]interface{}{"follow_ups": followUps}) + w.Write(resp) //nolint:errcheck + }) + fmt.Println("Starting followup-backend (pipeline demo) on :9016") + if err := http.ListenAndServe(":9016", mux); err != nil { //nolint:gosec + fmt.Printf("Backend server error on :9016: %v\n", err) + } + }() + + // ======================================== + // Backends for FAN-OUT-MERGE strategy demonstration + // ======================================== + + // Tickets backend (port 9017) - Returns support tickets + go func() { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"tickets":[{"id":"ticket-1","subject":"Login issue","status":"open","created":"2024-01-15"},{"id":"ticket-2","subject":"Billing question","status":"open","created":"2024-01-16"},{"id":"ticket-3","subject":"Feature request","status":"pending","created":"2024-01-17"}]}`) + }) + fmt.Println("Starting tickets-backend (fan-out-merge demo) on :9017") + if err := http.ListenAndServe(":9017", mux); err != nil { //nolint:gosec + fmt.Printf("Backend server error on :9017: %v\n", err) + } + }() + + // Assignments backend (port 9018) - Returns ticket assignments and priorities + go func() { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"assignments":{"ticket-1":{"assignee":"Alice","priority":"high","sla_deadline":"2024-01-16T12:00:00Z"},"ticket-3":{"assignee":"Bob","priority":"low","sla_deadline":"2024-01-20T12:00:00Z"}}}`) + }) + fmt.Println("Starting assignments-backend (fan-out-merge demo) on :9018") + if err := http.ListenAndServe(":9018", mux); err != nil { //nolint:gosec + fmt.Printf("Backend server error on :9018: %v\n", err) + } + }() } diff --git a/examples/testing-scenarios/go.mod b/examples/testing-scenarios/go.mod index 7cb34163..440db1e8 100644 --- a/examples/testing-scenarios/go.mod +++ b/examples/testing-scenarios/go.mod @@ -5,10 +5,10 @@ go 1.25 toolchain go1.25.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/chimux v0.0.0-00010101000000-000000000000 - github.com/CrisisTextLine/modular/modules/httpserver v0.0.0-00010101000000-000000000000 - github.com/CrisisTextLine/modular/modules/reverseproxy v0.0.0-00010101000000-000000000000 + github.com/GoCodeAlone/modular v1.11.11 + github.com/GoCodeAlone/modular/modules/chimux v0.0.0-00010101000000-000000000000 + github.com/GoCodeAlone/modular/modules/httpserver v0.0.0-00010101000000-000000000000 + github.com/GoCodeAlone/modular/modules/reverseproxy v0.0.0-00010101000000-000000000000 ) require ( @@ -26,10 +26,10 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -replace github.com/CrisisTextLine/modular/modules/chimux => ../../modules/chimux +replace github.com/GoCodeAlone/modular/modules/chimux => ../../modules/chimux -replace github.com/CrisisTextLine/modular/modules/httpserver => ../../modules/httpserver +replace github.com/GoCodeAlone/modular/modules/httpserver => ../../modules/httpserver -replace github.com/CrisisTextLine/modular/modules/reverseproxy => ../../modules/reverseproxy +replace github.com/GoCodeAlone/modular/modules/reverseproxy => ../../modules/reverseproxy diff --git a/examples/testing-scenarios/launchdarkly.go b/examples/testing-scenarios/launchdarkly.go index b553908e..7f14eed3 100644 --- a/examples/testing-scenarios/launchdarkly.go +++ b/examples/testing-scenarios/launchdarkly.go @@ -7,8 +7,8 @@ import ( "net/http" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) // LaunchDarklyConfig provides configuration for LaunchDarkly integration. diff --git a/examples/testing-scenarios/main.go b/examples/testing-scenarios/main.go index e30addc1..60e0db8d 100644 --- a/examples/testing-scenarios/main.go +++ b/examples/testing-scenarios/main.go @@ -16,11 +16,11 @@ import ( "syscall" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/chimux" - "github.com/CrisisTextLine/modular/modules/httpserver" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/chimux" + "github.com/GoCodeAlone/modular/modules/httpserver" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) type AppConfig struct { diff --git a/examples/verbose-debug/go.mod b/examples/verbose-debug/go.mod index 0f1fcbf5..e9c7299c 100644 --- a/examples/verbose-debug/go.mod +++ b/examples/verbose-debug/go.mod @@ -5,15 +5,15 @@ go 1.25 toolchain go1.25.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/database v1.4.0 + github.com/GoCodeAlone/modular v1.11.11 + github.com/GoCodeAlone/modular/modules/database v1.4.0 modernc.org/sqlite v1.38.0 ) require ( filippo.io/edwards25519 v1.1.1 // indirect github.com/BurntSushi/toml v1.6.0 // indirect - github.com/CrisisTextLine/modular/modules/database/v2 v2.2.0 // indirect + github.com/GoCodeAlone/modular/modules/database/v2 v2.2.0 // indirect github.com/aws/aws-sdk-go-v2 v1.38.3 // indirect github.com/aws/aws-sdk-go-v2/config v1.31.0 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.18.10 // indirect @@ -66,7 +66,7 @@ require ( ) // Use local module for development -replace github.com/CrisisTextLine/modular => ../.. +replace github.com/GoCodeAlone/modular => ../.. // Use local database module for development -replace github.com/CrisisTextLine/modular/modules/database => ../../modules/database +replace github.com/GoCodeAlone/modular/modules/database => ../../modules/database diff --git a/examples/verbose-debug/go.sum b/examples/verbose-debug/go.sum index 24f3010b..adab12eb 100644 --- a/examples/verbose-debug/go.sum +++ b/examples/verbose-debug/go.sum @@ -5,8 +5,8 @@ filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular/modules/database/v2 v2.2.0 h1:/U3RgMzuVQDZOmVW2dZsrT8eJUiQB2QHo2TLW9EPj9Y= -github.com/CrisisTextLine/modular/modules/database/v2 v2.2.0/go.mod h1:sDD70d2nKzNfJGUOGBmzecO+4nxRA641M5UDdblBNK4= +github.com/GoCodeAlone/modular/modules/database/v2 v2.2.0 h1:/U3RgMzuVQDZOmVW2dZsrT8eJUiQB2QHo2TLW9EPj9Y= +github.com/GoCodeAlone/modular/modules/database/v2 v2.2.0/go.mod h1:sDD70d2nKzNfJGUOGBmzecO+4nxRA641M5UDdblBNK4= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= diff --git a/examples/verbose-debug/main.go b/examples/verbose-debug/main.go index a337f4f2..84870103 100644 --- a/examples/verbose-debug/main.go +++ b/examples/verbose-debug/main.go @@ -6,9 +6,9 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/database" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/database" // Import SQLite driver for database connections _ "modernc.org/sqlite" diff --git a/features/health_contract.feature b/features/health_contract.feature new file mode 100644 index 00000000..354d0b32 --- /dev/null +++ b/features/health_contract.feature @@ -0,0 +1,47 @@ +Feature: Aggregate Health Contract + The health service must aggregate provider reports correctly. + + Scenario: Single healthy provider produces healthy status + Given a health service with one healthy provider + When health is checked + Then the overall status should be "healthy" + And readiness should be "healthy" + + Scenario: One unhealthy provider degrades overall health + Given a health service with one healthy and one unhealthy provider + When health is checked + Then the overall health should be "unhealthy" + And readiness should be "unhealthy" + + Scenario: Optional unhealthy provider does not affect readiness + Given a health service with one healthy required and one unhealthy optional provider + When health is checked + Then the overall health should be "unhealthy" + But readiness should be "healthy" + + Scenario: Provider panic is recovered gracefully + Given a health service with a provider that panics + When health is checked + Then the panicking provider should report "unhealthy" + And other providers should still be checked + + Scenario: Temporary error produces degraded status + Given a health service with a provider returning a temporary error + When health is checked + Then the provider status should be "degraded" + + Scenario: Cache returns previous result within TTL + Given a health service with a 100ms cache TTL + And a healthy provider + When health is checked twice within 50ms + Then the provider should only be called once + + Scenario: Force refresh bypasses cache + Given a health service with cached results + When health is checked with force refresh + Then the provider should be called again + + Scenario: Status change emits event + Given a health service with a provider that transitions from healthy to unhealthy + When health is checked after the transition + Then a health status changed event should be emitted diff --git a/features/reload_contract.feature b/features/reload_contract.feature new file mode 100644 index 00000000..4c3388ec --- /dev/null +++ b/features/reload_contract.feature @@ -0,0 +1,38 @@ +Feature: Dynamic Reload Contract + Modules implementing Reloadable must follow these behavioral contracts. + + Scenario: Successful reload applies changes to all reloadable modules + Given a reload orchestrator with 3 reloadable modules + When a reload is requested with configuration changes + Then all 3 modules should receive the changes + And a reload completed event should be emitted + + Scenario: Module refusing reload is skipped + Given a reload orchestrator with a module that cannot reload + When a reload is requested + Then the non-reloadable module should be skipped + And other modules should still be reloaded + + Scenario: Partial failure triggers rollback + Given a reload orchestrator with 3 modules where the second fails + When a reload is requested + Then the first module should be rolled back + And a reload failed event should be emitted + + Scenario: Circuit breaker activates after repeated failures + Given a reload orchestrator with a failing module + When 3 consecutive reloads fail + Then subsequent reload requests should be rejected + And the circuit breaker should eventually reset + + Scenario: Empty diff produces noop event + Given a reload orchestrator with reloadable modules + When a reload is requested with no changes + Then a reload noop event should be emitted + And no modules should be called + + Scenario: Concurrent reload requests are serialized + Given a reload orchestrator with reloadable modules + When 10 reload requests are submitted concurrently + Then all requests should be processed + And no race conditions should occur diff --git a/feeder_priority_test.go b/feeder_priority_test.go index 54417858..dfb47991 100644 --- a/feeder_priority_test.go +++ b/feeder_priority_test.go @@ -4,7 +4,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // TestFeederPriorityBasic tests the basic priority functionality @@ -344,7 +344,7 @@ func TestAffixedEnvFeederPriority(t *testing.T) { feeder := feeders.NewAffixedEnvFeeder("PREFIX_", "").WithPriority(100) // Verify priority was set - prioritized, ok := interface{}(feeder).(PrioritizedFeeder) + prioritized, ok := any(feeder).(PrioritizedFeeder) if !ok { t.Fatal("AffixedEnvFeeder does not implement PrioritizedFeeder interface") } @@ -413,7 +413,7 @@ func TestTenantAffixedEnvFeederPriority(t *testing.T) { tenantFeeder.SetPrefixFunc("tenant1") // Test priority was set correctly - prioritized, ok := interface{}(tenantFeeder).(PrioritizedFeeder) + prioritized, ok := any(tenantFeeder).(PrioritizedFeeder) if !ok { t.Fatal("TenantAffixedEnvFeeder does not implement PrioritizedFeeder interface") } diff --git a/field_tracker_bridge.go b/field_tracker_bridge.go index cda896c5..e05c045b 100644 --- a/field_tracker_bridge.go +++ b/field_tracker_bridge.go @@ -1,7 +1,7 @@ package modular import ( - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // FieldTrackerBridge adapts between the main package's FieldTracker interface diff --git a/go.mod b/go.mod index 62f34741..a53c44f6 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,14 @@ -module github.com/CrisisTextLine/modular +module github.com/GoCodeAlone/modular -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.1 require ( github.com/BurntSushi/toml v1.6.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 + github.com/fsnotify/fsnotify v1.9.0 github.com/golobby/cast v1.3.3 github.com/google/uuid v1.6.0 github.com/stretchr/testify v1.11.1 @@ -32,4 +33,5 @@ require ( github.com/stretchr/objx v0.5.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect + golang.org/x/sys v0.21.0 // indirect ) diff --git a/go.sum b/go.sum index 6d98ce0d..bef74aad 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= @@ -84,6 +86,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/health.go b/health.go new file mode 100644 index 00000000..7344e98f --- /dev/null +++ b/health.go @@ -0,0 +1,141 @@ +package modular + +import ( + "context" + "fmt" + "time" +) + +// HealthStatus represents the health state of a component. +type HealthStatus int + +const ( + // StatusUnknown indicates the health state has not been determined. + StatusUnknown HealthStatus = iota + // StatusHealthy indicates the component is functioning normally. + StatusHealthy + // StatusDegraded indicates the component is functioning with reduced capability. + StatusDegraded + // StatusUnhealthy indicates the component is not functioning. + StatusUnhealthy +) + +// String returns the string representation of a HealthStatus. +func (s HealthStatus) String() string { + switch s { + case StatusUnknown: + return "unknown" + case StatusHealthy: + return "healthy" + case StatusDegraded: + return "degraded" + case StatusUnhealthy: + return "unhealthy" + default: + return "unknown" + } +} + +// IsHealthy returns true if the status is StatusHealthy. +func (s HealthStatus) IsHealthy() bool { + return s == StatusHealthy +} + +// HealthProvider is an interface for components that can report their health. +type HealthProvider interface { + HealthCheck(ctx context.Context) ([]HealthReport, error) +} + +// HealthReport represents the health status of a single component. +type HealthReport struct { + Module string + Component string + Status HealthStatus + Message string + CheckedAt time.Time + ObservedSince time.Time + Optional bool + Details map[string]any +} + +// AggregatedHealth represents the combined health of all providers. +type AggregatedHealth struct { + Readiness HealthStatus + Health HealthStatus + Reports []HealthReport + GeneratedAt time.Time +} + +// forceHealthRefreshKeyType is an unexported type for context key safety. +type forceHealthRefreshKeyType struct{} + +// ForceHealthRefreshKey is the context key used to force a health refresh, +// bypassing the cache. Usage: context.WithValue(ctx, modular.ForceHealthRefreshKey, true) +var ForceHealthRefreshKey = forceHealthRefreshKeyType{} + +// simpleHealthProvider adapts a function into a HealthProvider. +type simpleHealthProvider struct { + module string + component string + fn func(ctx context.Context) (HealthStatus, string, error) +} + +// NewSimpleHealthProvider creates a HealthProvider from a function that returns +// a status, message, and error. +func NewSimpleHealthProvider(module, component string, fn func(ctx context.Context) (HealthStatus, string, error)) HealthProvider { + return &simpleHealthProvider{module: module, component: component, fn: fn} +} + +func (p *simpleHealthProvider) HealthCheck(ctx context.Context) ([]HealthReport, error) { + status, msg, err := p.fn(ctx) + report := HealthReport{ + Module: p.module, + Component: p.component, + Status: status, + Message: msg, + CheckedAt: time.Now(), + } + return []HealthReport{report}, err +} + +// staticHealthProvider returns fixed reports. +type staticHealthProvider struct { + reports []HealthReport +} + +// NewStaticHealthProvider creates a HealthProvider that always returns the given reports. +func NewStaticHealthProvider(reports ...HealthReport) HealthProvider { + return &staticHealthProvider{reports: reports} +} + +func (p *staticHealthProvider) HealthCheck(_ context.Context) ([]HealthReport, error) { + now := time.Now() + result := make([]HealthReport, len(p.reports)) + copy(result, p.reports) + for i := range result { + result[i].CheckedAt = now + } + return result, nil +} + +// compositeHealthProvider aggregates multiple providers into one. +type compositeHealthProvider struct { + providers []HealthProvider +} + +// NewCompositeHealthProvider creates a HealthProvider that delegates to multiple providers. +func NewCompositeHealthProvider(providers ...HealthProvider) HealthProvider { + return &compositeHealthProvider{providers: providers} +} + +func (p *compositeHealthProvider) HealthCheck(ctx context.Context) ([]HealthReport, error) { + var all []HealthReport + for _, provider := range p.providers { + reports, err := provider.HealthCheck(ctx) + if err != nil { + return all, fmt.Errorf("composite health check: %w", err) + } + all = append(all, reports...) + } + return all, nil +} diff --git a/health_contract_bdd_test.go b/health_contract_bdd_test.go new file mode 100644 index 00000000..789b3265 --- /dev/null +++ b/health_contract_bdd_test.go @@ -0,0 +1,452 @@ +package modular + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/cucumber/godog" +) + +// Static errors for health contract BDD tests. +var ( + errExpectedOverallHealthy = errors.New("expected overall status to be healthy") + errExpectedOverallUnhealthy = errors.New("expected overall status to be unhealthy") + errExpectedReadinessHealthy = errors.New("expected readiness to be healthy") + errExpectedReadinessUnhealthy = errors.New("expected readiness to be unhealthy") + errExpectedPanicUnhealthy = errors.New("expected panicking provider to report unhealthy") + errExpectedOtherProvidersChecked = errors.New("expected other providers to still be checked") + errExpectedDegradedStatus = errors.New("expected provider status to be degraded") + errExpectedSingleCall = errors.New("expected provider to be called only once") + errExpectedRefreshCall = errors.New("expected provider to be called again on refresh") + errExpectedStatusChangedEvent = errors.New("expected health status changed event") +) + +// healthBDDProvider is a configurable mock HealthProvider for BDD tests. +type healthBDDProvider struct { + reports []HealthReport + err error + callCount atomic.Int32 + panicMsg string + mu sync.Mutex +} + +func (p *healthBDDProvider) HealthCheck(_ context.Context) ([]HealthReport, error) { + p.callCount.Add(1) + if p.panicMsg != "" { + panic(p.panicMsg) + } + p.mu.Lock() + defer p.mu.Unlock() + if p.err != nil { + return nil, p.err + } + reports := make([]HealthReport, len(p.reports)) + copy(reports, p.reports) + for i := range reports { + reports[i].CheckedAt = time.Now() + } + return reports, nil +} + +func (p *healthBDDProvider) setReports(reports []HealthReport) { + p.mu.Lock() + defer p.mu.Unlock() + p.reports = reports +} + +// bddTemporaryError implements the Temporary() bool interface for degraded status. +type bddTemporaryError struct { + msg string +} + +func (e *bddTemporaryError) Error() string { return e.msg } +func (e *bddTemporaryError) Temporary() bool { return true } + +// healthBDDSubject captures events for BDD health contract tests. +type healthBDDSubject struct { + mu sync.Mutex + events []cloudevents.Event +} + +func (s *healthBDDSubject) RegisterObserver(_ Observer, _ ...string) error { return nil } +func (s *healthBDDSubject) UnregisterObserver(_ Observer) error { return nil } +func (s *healthBDDSubject) GetObservers() []ObserverInfo { return nil } +func (s *healthBDDSubject) NotifyObservers(_ context.Context, event cloudevents.Event) error { + s.mu.Lock() + s.events = append(s.events, event) + s.mu.Unlock() + return nil +} + +func (s *healthBDDSubject) hasEventType(eventType string) bool { + s.mu.Lock() + defer s.mu.Unlock() + for _, e := range s.events { + if e.Type() == eventType { + return true + } + } + return false +} + + + +// HealthBDDContext holds state for health contract BDD scenarios. +type HealthBDDContext struct { + service *AggregateHealthService + subject *healthBDDSubject + providers map[string]*healthBDDProvider + result *AggregatedHealth + checkErr error +} + +func (hc *HealthBDDContext) reset() { + hc.subject = &healthBDDSubject{} + hc.providers = make(map[string]*healthBDDProvider) + hc.service = nil + hc.result = nil + hc.checkErr = nil +} + +func (hc *HealthBDDContext) ensureService() { + if hc.service == nil { + hc.service = NewAggregateHealthService( + WithSubject(hc.subject), + WithCacheTTL(250*time.Millisecond), + ) + } +} + +// Step definitions + +func (hc *HealthBDDContext) aHealthServiceWithOneHealthyProvider() error { + hc.ensureService() + p := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "healthy-mod", + Component: "main", + Status: StatusHealthy, + Message: "ok", + }}, + } + hc.providers["healthy"] = p + hc.service.AddProvider("healthy", p) + return nil +} + +func (hc *HealthBDDContext) healthIsChecked() error { + hc.result, hc.checkErr = hc.service.Check(context.Background()) + return nil +} + +func (hc *HealthBDDContext) theOverallStatusShouldBe(expected string) error { + if hc.result.Health.String() != expected { + if expected == "healthy" { + return errExpectedOverallHealthy + } + return errExpectedOverallUnhealthy + } + return nil +} + +func (hc *HealthBDDContext) readinessShouldBe(expected string) error { + if hc.result.Readiness.String() != expected { + if expected == "healthy" { + return errExpectedReadinessHealthy + } + return errExpectedReadinessUnhealthy + } + return nil +} + +func (hc *HealthBDDContext) aHealthServiceWithOneHealthyAndOneUnhealthyProvider() error { + hc.ensureService() + healthy := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "healthy-mod", + Component: "main", + Status: StatusHealthy, + Message: "ok", + }}, + } + unhealthy := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "unhealthy-mod", + Component: "main", + Status: StatusUnhealthy, + Message: "down", + }}, + } + hc.providers["healthy"] = healthy + hc.providers["unhealthy"] = unhealthy + hc.service.AddProvider("healthy", healthy) + hc.service.AddProvider("unhealthy", unhealthy) + return nil +} + +func (hc *HealthBDDContext) theOverallHealthShouldBe(expected string) error { + return hc.theOverallStatusShouldBe(expected) +} + +func (hc *HealthBDDContext) aHealthServiceWithOneHealthyRequiredAndOneUnhealthyOptionalProvider() error { + hc.ensureService() + required := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "required-mod", + Component: "main", + Status: StatusHealthy, + Message: "ok", + Optional: false, + }}, + } + optional := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "optional-mod", + Component: "aux", + Status: StatusUnhealthy, + Message: "not critical", + Optional: true, + }}, + } + hc.providers["required"] = required + hc.providers["optional"] = optional + hc.service.AddProvider("required", required) + hc.service.AddProvider("optional", optional) + return nil +} + +func (hc *HealthBDDContext) aHealthServiceWithAProviderThatPanics() error { + hc.ensureService() + panicker := &healthBDDProvider{ + panicMsg: "something went terribly wrong", + } + stable := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "stable-mod", + Component: "main", + Status: StatusHealthy, + Message: "ok", + }}, + } + hc.providers["panicker"] = panicker + hc.providers["stable"] = stable + hc.service.AddProvider("panicker", panicker) + hc.service.AddProvider("stable", stable) + return nil +} + +func (hc *HealthBDDContext) thePanickingProviderShouldReport(expected string) error { + for _, r := range hc.result.Reports { + if r.Component == "panic-recovery" { + if r.Status.String() != expected { + return errExpectedPanicUnhealthy + } + return nil + } + } + return errExpectedPanicUnhealthy +} + +func (hc *HealthBDDContext) otherProvidersShouldStillBeChecked() error { + for _, r := range hc.result.Reports { + if r.Module == "stable-mod" { + return nil + } + } + return errExpectedOtherProvidersChecked +} + +func (hc *HealthBDDContext) aHealthServiceWithAProviderReturningATemporaryError() error { + hc.ensureService() + p := &healthBDDProvider{ + err: &bddTemporaryError{msg: "transient issue"}, + } + hc.providers["temp-err"] = p + hc.service.AddProvider("temp-err", p) + return nil +} + +func (hc *HealthBDDContext) theProviderStatusShouldBe(expected string) error { + for _, r := range hc.result.Reports { + if r.Status.String() == expected { + return nil + } + } + return errExpectedDegradedStatus +} + +func (hc *HealthBDDContext) aHealthServiceWithA100msCacheTTL() error { + hc.service = NewAggregateHealthService( + WithSubject(hc.subject), + WithCacheTTL(100*time.Millisecond), + ) + return nil +} + +func (hc *HealthBDDContext) aHealthyProvider() error { + p := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "cached-mod", + Component: "main", + Status: StatusHealthy, + Message: "ok", + }}, + } + hc.providers["cached"] = p + hc.service.AddProvider("cached", p) + return nil +} + +func (hc *HealthBDDContext) healthIsCheckedTwiceWithin50ms() error { + hc.result, hc.checkErr = hc.service.Check(context.Background()) + if hc.checkErr != nil { + return hc.checkErr + } + // Second check within cache TTL + time.Sleep(10 * time.Millisecond) + hc.result, hc.checkErr = hc.service.Check(context.Background()) + return nil +} + +func (hc *HealthBDDContext) theProviderShouldOnlyBeCalledOnce() error { + p := hc.providers["cached"] + if p.callCount.Load() != 1 { + return errExpectedSingleCall + } + return nil +} + +func (hc *HealthBDDContext) aHealthServiceWithCachedResults() error { + hc.service = NewAggregateHealthService( + WithSubject(hc.subject), + WithCacheTTL(10*time.Second), + ) + p := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "refresh-mod", + Component: "main", + Status: StatusHealthy, + Message: "ok", + }}, + } + hc.providers["refresh"] = p + hc.service.AddProvider("refresh", p) + // Prime the cache + _, _ = hc.service.Check(context.Background()) + return nil +} + +func (hc *HealthBDDContext) healthIsCheckedWithForceRefresh() error { + ctx := context.WithValue(context.Background(), ForceHealthRefreshKey, true) + hc.result, hc.checkErr = hc.service.Check(ctx) + return nil +} + +func (hc *HealthBDDContext) theProviderShouldBeCalledAgain() error { + p := hc.providers["refresh"] + if p.callCount.Load() < 2 { + return errExpectedRefreshCall + } + return nil +} + +func (hc *HealthBDDContext) aHealthServiceWithAProviderThatTransitionsFromHealthyToUnhealthy() error { + hc.ensureService() + p := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "transitioning-mod", + Component: "main", + Status: StatusHealthy, + Message: "ok", + }}, + } + hc.providers["transitioning"] = p + hc.service.AddProvider("transitioning", p) + + // Do initial check to establish healthy baseline, then invalidate cache. + _, _ = hc.service.Check(context.Background()) + hc.service.invalidateCache() + + // Transition to unhealthy. + p.setReports([]HealthReport{{ + Module: "transitioning-mod", + Component: "main", + Status: StatusUnhealthy, + Message: "went down", + }}) + return nil +} + +func (hc *HealthBDDContext) healthIsCheckedAfterTheTransition() error { + hc.result, hc.checkErr = hc.service.Check(context.Background()) + return nil +} + +func (hc *HealthBDDContext) aHealthStatusChangedEventShouldBeEmitted() error { + if hc.subject.hasEventType(EventTypeHealthStatusChanged) { + return nil + } + return errExpectedStatusChangedEvent +} + +// InitializeHealthContractScenario wires up all health contract BDD steps. +func InitializeHealthContractScenario(ctx *godog.ScenarioContext) { + hc := &HealthBDDContext{} + + ctx.Before(func(ctx context.Context, _ *godog.Scenario) (context.Context, error) { + hc.reset() + return ctx, nil + }) + + ctx.Step(`^a health service with one healthy provider$`, hc.aHealthServiceWithOneHealthyProvider) + ctx.Step(`^health is checked$`, hc.healthIsChecked) + ctx.Step(`^the overall status should be "([^"]*)"$`, hc.theOverallStatusShouldBe) + ctx.Step(`^readiness should be "([^"]*)"$`, hc.readinessShouldBe) + + ctx.Step(`^a health service with one healthy and one unhealthy provider$`, hc.aHealthServiceWithOneHealthyAndOneUnhealthyProvider) + ctx.Step(`^the overall health should be "([^"]*)"$`, hc.theOverallHealthShouldBe) + + ctx.Step(`^a health service with one healthy required and one unhealthy optional provider$`, hc.aHealthServiceWithOneHealthyRequiredAndOneUnhealthyOptionalProvider) + + ctx.Step(`^a health service with a provider that panics$`, hc.aHealthServiceWithAProviderThatPanics) + ctx.Step(`^the panicking provider should report "([^"]*)"$`, hc.thePanickingProviderShouldReport) + ctx.Step(`^other providers should still be checked$`, hc.otherProvidersShouldStillBeChecked) + + ctx.Step(`^a health service with a provider returning a temporary error$`, hc.aHealthServiceWithAProviderReturningATemporaryError) + ctx.Step(`^the provider status should be "([^"]*)"$`, hc.theProviderStatusShouldBe) + + ctx.Step(`^a health service with a 100ms cache TTL$`, hc.aHealthServiceWithA100msCacheTTL) + ctx.Step(`^a healthy provider$`, hc.aHealthyProvider) + ctx.Step(`^health is checked twice within 50ms$`, hc.healthIsCheckedTwiceWithin50ms) + ctx.Step(`^the provider should only be called once$`, hc.theProviderShouldOnlyBeCalledOnce) + + ctx.Step(`^a health service with cached results$`, hc.aHealthServiceWithCachedResults) + ctx.Step(`^health is checked with force refresh$`, hc.healthIsCheckedWithForceRefresh) + ctx.Step(`^the provider should be called again$`, hc.theProviderShouldBeCalledAgain) + + ctx.Step(`^a health service with a provider that transitions from healthy to unhealthy$`, hc.aHealthServiceWithAProviderThatTransitionsFromHealthyToUnhealthy) + ctx.Step(`^health is checked after the transition$`, hc.healthIsCheckedAfterTheTransition) + ctx.Step(`^a health status changed event should be emitted$`, hc.aHealthStatusChangedEventShouldBeEmitted) +} + +// TestHealthContractBDD runs the BDD tests for the health contract. +func TestHealthContractBDD(t *testing.T) { + suite := godog.TestSuite{ + ScenarioInitializer: InitializeHealthContractScenario, + Options: &godog.Options{ + Format: "pretty", + Paths: []string{"features/health_contract.feature"}, + TestingT: t, + Strict: true, + }, + } + + if suite.Run() != 0 { + t.Fatal("non-zero status returned, failed to run health contract feature tests") + } +} diff --git a/health_service.go b/health_service.go new file mode 100644 index 00000000..c397e295 --- /dev/null +++ b/health_service.go @@ -0,0 +1,291 @@ +package modular + +import ( + "maps" + "context" + "fmt" + "sync" + "time" +) + +// AggregateHealthService collects health reports from registered providers +// and produces an aggregated health result with caching and event emission. +type AggregateHealthService struct { + providers map[string]HealthProvider + mu sync.RWMutex + cache *AggregatedHealth + cacheMu sync.RWMutex + cacheExpiry time.Time + cacheTTL time.Duration + lastStatus HealthStatus + subject Subject + logger Logger +} + +// HealthServiceOption configures an AggregateHealthService. +type HealthServiceOption func(*AggregateHealthService) + +// WithCacheTTL sets the cache time-to-live for health check results. +func WithCacheTTL(d time.Duration) HealthServiceOption { + return func(s *AggregateHealthService) { + s.cacheTTL = d + } +} + +// WithSubject sets the event subject for health event emission. +func WithSubject(sub Subject) HealthServiceOption { + return func(s *AggregateHealthService) { + s.subject = sub + } +} + +// WithHealthLogger sets the structured logger for the health service. +func WithHealthLogger(l Logger) HealthServiceOption { + return func(s *AggregateHealthService) { + s.logger = l + } +} + +// NewAggregateHealthService creates a new AggregateHealthService with the given options. +func NewAggregateHealthService(opts ...HealthServiceOption) *AggregateHealthService { + svc := &AggregateHealthService{ + providers: make(map[string]HealthProvider), + cacheTTL: 250 * time.Millisecond, + lastStatus: StatusUnknown, + } + for _, opt := range opts { + opt(svc) + } + return svc +} + +// AddProvider registers a named health provider and invalidates the cache. +func (s *AggregateHealthService) AddProvider(name string, provider HealthProvider) { + s.mu.Lock() + s.providers[name] = provider + s.mu.Unlock() + s.invalidateCache() +} + +// RemoveProvider removes a named health provider and invalidates the cache. +func (s *AggregateHealthService) RemoveProvider(name string) { + s.mu.Lock() + delete(s.providers, name) + s.mu.Unlock() + s.invalidateCache() +} + +func (s *AggregateHealthService) invalidateCache() { + s.cacheMu.Lock() + s.cache = nil + s.cacheExpiry = time.Time{} + s.cacheMu.Unlock() +} + +// providerResult is used to collect results from concurrent provider checks. +type providerResult struct { + reports []HealthReport + err error + name string +} + +// Check evaluates all registered providers and returns an aggregated health result. +// Results are cached for the configured TTL unless ForceHealthRefreshKey is set in the context. +// The returned AggregatedHealth is a deep copy and safe to mutate. +func (s *AggregateHealthService) Check(ctx context.Context) (*AggregatedHealth, error) { + // Check cache validity + forceRefresh, _ := ctx.Value(ForceHealthRefreshKey).(bool) + if !forceRefresh { + s.cacheMu.RLock() + if s.cache != nil && time.Now().Before(s.cacheExpiry) { + copied := s.deepCopyAggregated(s.cache) + s.cacheMu.RUnlock() + return copied, nil + } + s.cacheMu.RUnlock() + } + + // Snapshot providers under read lock + s.mu.RLock() + providers := make(map[string]HealthProvider, len(s.providers)) + maps.Copy(providers, s.providers) + s.mu.RUnlock() + + // Fan-out to all providers + ch := make(chan providerResult, len(providers)) + for name, provider := range providers { + go func(name string, provider HealthProvider) { + result := providerResult{name: name} + defer func() { + if r := recover(); r != nil { + result.reports = []HealthReport{{ + Module: name, + Component: "panic-recovery", + Status: StatusUnhealthy, + Message: fmt.Sprintf("provider panicked: %v", r), + CheckedAt: time.Now(), + }} + result.err = nil + ch <- result + } + }() + reports, err := provider.HealthCheck(ctx) + result.reports = reports + result.err = err + ch <- result + }(name, provider) + } + + // Collect results + var allReports []HealthReport + readiness := StatusHealthy + health := StatusHealthy + + for range len(providers) { + var result providerResult + select { + case result = <-ch: + case <-ctx.Done(): + return nil, fmt.Errorf("health check interrupted: %w", ctx.Err()) + } + + if result.err != nil { + // Check if error is temporary + status := StatusUnhealthy + if te, ok := result.err.(interface{ Temporary() bool }); ok && te.Temporary() { + status = StatusDegraded + } + // Add error report + allReports = append(allReports, HealthReport{ + Module: result.name, + Component: "error", + Status: status, + Message: result.err.Error(), + CheckedAt: time.Now(), + }) + readiness = worstStatus(readiness, status) + health = worstStatus(health, status) + continue + } + + for _, report := range result.reports { + allReports = append(allReports, report) + health = worstStatus(health, report.Status) + if !report.Optional { + readiness = worstStatus(readiness, report.Status) + } + } + } + + aggregated := &AggregatedHealth{ + Readiness: readiness, + Health: health, + Reports: allReports, + GeneratedAt: time.Now(), + } + + // Cache result + s.cacheMu.Lock() + s.cache = aggregated + s.cacheExpiry = time.Now().Add(s.cacheTTL) + s.cacheMu.Unlock() + + // Emit events + s.emitHealthEvaluated(ctx, aggregated) + + s.cacheMu.Lock() + previousStatus := s.lastStatus + s.lastStatus = aggregated.Health + s.cacheMu.Unlock() + + if previousStatus != aggregated.Health { + s.emitHealthStatusChanged(ctx, previousStatus, aggregated.Health) + } + + return s.deepCopyAggregated(aggregated), nil +} + +// deepCopyAggregated returns a deep copy of an AggregatedHealth, including +// reports and their Details maps, so callers cannot mutate cached state. +func (s *AggregateHealthService) deepCopyAggregated(src *AggregatedHealth) *AggregatedHealth { + if src == nil { + return nil + } + dst := &AggregatedHealth{ + Readiness: src.Readiness, + Health: src.Health, + GeneratedAt: src.GeneratedAt, + Reports: make([]HealthReport, len(src.Reports)), + } + for i, r := range src.Reports { + dst.Reports[i] = r + if r.Details != nil { + dst.Reports[i].Details = make(map[string]any, len(r.Details)) + maps.Copy(dst.Reports[i].Details, r.Details) + } + } + return dst +} + +func (s *AggregateHealthService) emitHealthEvaluated(ctx context.Context, agg *AggregatedHealth) { + if s.subject == nil { + return + } + event := NewCloudEvent(EventTypeHealthEvaluated, "modular/health-service", map[string]any{ + "readiness": agg.Readiness.String(), + "health": agg.Health.String(), + "report_count": len(agg.Reports), + }, nil) + if err := s.subject.NotifyObservers(ctx, event); err != nil && s.logger != nil { + s.logger.Debug("Failed to emit health evaluated event", "error", err) + } +} + +func (s *AggregateHealthService) emitHealthStatusChanged(ctx context.Context, from, to HealthStatus) { + if s.subject == nil { + return + } + event := NewCloudEvent(EventTypeHealthStatusChanged, "modular/health-service", map[string]any{ + "previous_status": from.String(), + "current_status": to.String(), + }, nil) + if err := s.subject.NotifyObservers(ctx, event); err != nil && s.logger != nil { + s.logger.Debug("Failed to emit health status changed event", "error", err) + } +} + +// worstStatus returns the worse of two health statuses. +// StatusUnknown is treated as StatusUnhealthy for aggregation purposes: +// if either status is Unknown, it is mapped to Unhealthy in the result +// so that the aggregated output consistently reflects unhealthy severity. +func worstStatus(a, b HealthStatus) HealthStatus { + ar := normalizeForAggregation(a) + br := normalizeForAggregation(b) + var winner HealthStatus + if ar >= br { + winner = a + } else { + winner = b + } + // Map Unknown → Unhealthy so aggregated health never reports "unknown". + if winner == StatusUnknown { + return StatusUnhealthy + } + return winner +} + +// normalizeForAggregation maps StatusUnknown to StatusUnhealthy severity for comparison. +func normalizeForAggregation(s HealthStatus) int { + switch s { + case StatusHealthy: + return 0 + case StatusDegraded: + return 1 + case StatusUnhealthy: + return 2 + case StatusUnknown: + return 2 // Unknown treated as Unhealthy + default: + return 2 + } +} diff --git a/health_test.go b/health_test.go new file mode 100644 index 00000000..96e47683 --- /dev/null +++ b/health_test.go @@ -0,0 +1,435 @@ +package modular + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + cloudevents "github.com/cloudevents/sdk-go/v2" +) + +func TestHealthStatus_String(t *testing.T) { + tests := []struct { + status HealthStatus + want string + }{ + {StatusUnknown, "unknown"}, + {StatusHealthy, "healthy"}, + {StatusDegraded, "degraded"}, + {StatusUnhealthy, "unhealthy"}, + {HealthStatus(99), "unknown"}, + } + for _, tt := range tests { + if got := tt.status.String(); got != tt.want { + t.Errorf("HealthStatus(%d).String() = %q, want %q", tt.status, got, tt.want) + } + } +} + +func TestHealthStatus_IsHealthy(t *testing.T) { + if !StatusHealthy.IsHealthy() { + t.Error("StatusHealthy.IsHealthy() should be true") + } + for _, s := range []HealthStatus{StatusUnknown, StatusDegraded, StatusUnhealthy} { + if s.IsHealthy() { + t.Errorf("%v.IsHealthy() should be false", s) + } + } +} + +func TestSimpleHealthProvider(t *testing.T) { + provider := NewSimpleHealthProvider("mymod", "db", func(_ context.Context) (HealthStatus, string, error) { + return StatusHealthy, "all good", nil + }) + reports, err := provider.HealthCheck(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(reports) != 1 { + t.Fatalf("expected 1 report, got %d", len(reports)) + } + r := reports[0] + if r.Module != "mymod" || r.Component != "db" || r.Status != StatusHealthy || r.Message != "all good" { + t.Errorf("unexpected report: %+v", r) + } + if r.CheckedAt.IsZero() { + t.Error("CheckedAt should be set") + } +} + +func TestStaticHealthProvider(t *testing.T) { + report := HealthReport{ + Module: "static", + Component: "cache", + Status: StatusDegraded, + Message: "warming up", + } + provider := NewStaticHealthProvider(report) + reports, err := provider.HealthCheck(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(reports) != 1 { + t.Fatalf("expected 1 report, got %d", len(reports)) + } + if reports[0].Status != StatusDegraded { + t.Errorf("expected degraded, got %v", reports[0].Status) + } + if reports[0].CheckedAt.IsZero() { + t.Error("CheckedAt should be set by static provider") + } +} + +func TestCompositeHealthProvider(t *testing.T) { + p1 := NewStaticHealthProvider(HealthReport{Module: "a", Component: "1", Status: StatusHealthy}) + p2 := NewStaticHealthProvider(HealthReport{Module: "b", Component: "2", Status: StatusDegraded}) + composite := NewCompositeHealthProvider(p1, p2) + + reports, err := composite.HealthCheck(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(reports) != 2 { + t.Fatalf("expected 2 reports, got %d", len(reports)) + } +} + +// testSubject is a minimal Subject implementation for testing event emission. +type testSubject struct { + mu sync.Mutex + events []cloudevents.Event +} + +func (s *testSubject) RegisterObserver(_ Observer, _ ...string) error { return nil } +func (s *testSubject) UnregisterObserver(_ Observer) error { return nil } +func (s *testSubject) GetObservers() []ObserverInfo { return nil } +func (s *testSubject) NotifyObservers(_ context.Context, event cloudevents.Event) error { + s.mu.Lock() + s.events = append(s.events, event) + s.mu.Unlock() + return nil +} +func (s *testSubject) getEvents() []cloudevents.Event { + s.mu.Lock() + defer s.mu.Unlock() + result := make([]cloudevents.Event, len(s.events)) + copy(result, s.events) + return result +} + +func TestAggregateHealthService_SingleProvider(t *testing.T) { + svc := NewAggregateHealthService() + svc.AddProvider("db", NewStaticHealthProvider(HealthReport{ + Module: "db", Component: "conn", Status: StatusHealthy, Message: "ok", + })) + + result, err := svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Health != StatusHealthy { + t.Errorf("expected healthy, got %v", result.Health) + } + if result.Readiness != StatusHealthy { + t.Errorf("expected readiness healthy, got %v", result.Readiness) + } + if len(result.Reports) != 1 { + t.Errorf("expected 1 report, got %d", len(result.Reports)) + } +} + +func TestAggregateHealthService_MultipleProviders(t *testing.T) { + svc := NewAggregateHealthService() + svc.AddProvider("db", NewStaticHealthProvider(HealthReport{ + Module: "db", Component: "conn", Status: StatusHealthy, + })) + svc.AddProvider("cache", NewStaticHealthProvider(HealthReport{ + Module: "cache", Component: "redis", Status: StatusDegraded, + })) + + result, err := svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Health != StatusDegraded { + t.Errorf("expected degraded health, got %v", result.Health) + } + if result.Readiness != StatusDegraded { + t.Errorf("expected degraded readiness, got %v", result.Readiness) + } + if len(result.Reports) != 2 { + t.Errorf("expected 2 reports, got %d", len(result.Reports)) + } +} + +func TestAggregateHealthService_OptionalVsRequired(t *testing.T) { + svc := NewAggregateHealthService() + svc.AddProvider("db", NewStaticHealthProvider(HealthReport{ + Module: "db", Component: "conn", Status: StatusHealthy, + })) + svc.AddProvider("metrics", NewStaticHealthProvider(HealthReport{ + Module: "metrics", Component: "export", Status: StatusUnhealthy, Optional: true, + })) + + result, err := svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Health reflects all components (worst = unhealthy) + if result.Health != StatusUnhealthy { + t.Errorf("expected unhealthy health (includes optional), got %v", result.Health) + } + // Readiness only reflects required components (should be healthy) + if result.Readiness != StatusHealthy { + t.Errorf("expected healthy readiness (optional excluded), got %v", result.Readiness) + } +} + +func TestAggregateHealthService_CacheHit(t *testing.T) { + callCount := 0 + provider := NewSimpleHealthProvider("mod", "comp", func(_ context.Context) (HealthStatus, string, error) { + callCount++ + return StatusHealthy, "ok", nil + }) + + svc := NewAggregateHealthService(WithCacheTTL(1 * time.Second)) + svc.AddProvider("test", provider) + + // First call + _, err := svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if callCount != 1 { + t.Fatalf("expected 1 call, got %d", callCount) + } + + // Second call within TTL should be cached + _, err = svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if callCount != 1 { + t.Errorf("expected 1 call (cached), got %d", callCount) + } +} + +func TestAggregateHealthService_CacheMiss(t *testing.T) { + callCount := 0 + provider := NewSimpleHealthProvider("mod", "comp", func(_ context.Context) (HealthStatus, string, error) { + callCount++ + return StatusHealthy, "ok", nil + }) + + svc := NewAggregateHealthService(WithCacheTTL(1 * time.Millisecond)) + svc.AddProvider("test", provider) + + _, err := svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Wait for cache to expire + time.Sleep(5 * time.Millisecond) + + _, err = svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if callCount != 2 { + t.Errorf("expected 2 calls after cache expiry, got %d", callCount) + } +} + +func TestAggregateHealthService_CacheInvalidation(t *testing.T) { + callCount := 0 + provider := NewSimpleHealthProvider("mod", "comp", func(_ context.Context) (HealthStatus, string, error) { + callCount++ + return StatusHealthy, "ok", nil + }) + + svc := NewAggregateHealthService(WithCacheTTL(10 * time.Second)) + svc.AddProvider("test", provider) + + _, _ = svc.Check(context.Background()) + if callCount != 1 { + t.Fatalf("expected 1 call, got %d", callCount) + } + + // AddProvider should invalidate cache + svc.AddProvider("another", NewStaticHealthProvider(HealthReport{ + Module: "x", Component: "y", Status: StatusHealthy, + })) + + _, _ = svc.Check(context.Background()) + if callCount != 2 { + t.Errorf("expected 2 calls after AddProvider invalidation, got %d", callCount) + } + + // RemoveProvider should also invalidate + svc.RemoveProvider("another") + _, _ = svc.Check(context.Background()) + if callCount != 3 { + t.Errorf("expected 3 calls after RemoveProvider invalidation, got %d", callCount) + } +} + +func TestAggregateHealthService_ForceRefresh(t *testing.T) { + callCount := 0 + provider := NewSimpleHealthProvider("mod", "comp", func(_ context.Context) (HealthStatus, string, error) { + callCount++ + return StatusHealthy, "ok", nil + }) + + svc := NewAggregateHealthService(WithCacheTTL(10 * time.Second)) + svc.AddProvider("test", provider) + + _, _ = svc.Check(context.Background()) + if callCount != 1 { + t.Fatalf("expected 1 call, got %d", callCount) + } + + // Force refresh bypasses cache + ctx := context.WithValue(context.Background(), ForceHealthRefreshKey, true) + _, _ = svc.Check(ctx) + if callCount != 2 { + t.Errorf("expected 2 calls after force refresh, got %d", callCount) + } +} + +func TestAggregateHealthService_PanicRecovery(t *testing.T) { + panicProvider := NewSimpleHealthProvider("panicky", "boom", func(_ context.Context) (HealthStatus, string, error) { + panic("something went wrong") + }) + + svc := NewAggregateHealthService() + svc.AddProvider("panicky", panicProvider) + + result, err := svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Health != StatusUnhealthy { + t.Errorf("expected unhealthy after panic, got %v", result.Health) + } + // Check that the panic report is present + found := false + for _, r := range result.Reports { + if r.Status == StatusUnhealthy && r.Component == "panic-recovery" { + found = true + break + } + } + if !found { + t.Error("expected panic recovery report in results") + } +} + +// temporaryError implements the Temporary() interface. +type temporaryError struct { + msg string +} + +func (e *temporaryError) Error() string { return e.msg } +func (e *temporaryError) Temporary() bool { return true } + +func TestAggregateHealthService_TemporaryError(t *testing.T) { + provider := NewSimpleHealthProvider("net", "conn", func(_ context.Context) (HealthStatus, string, error) { + return StatusUnknown, "", &temporaryError{msg: "connection timeout"} + }) + + svc := NewAggregateHealthService() + svc.AddProvider("net", provider) + + result, err := svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Health != StatusDegraded { + t.Errorf("expected degraded for temporary error, got %v", result.Health) + } +} + +func TestAggregateHealthService_PermanentError(t *testing.T) { + provider := NewSimpleHealthProvider("db", "conn", func(_ context.Context) (HealthStatus, string, error) { + return StatusUnknown, "", errors.New("connection refused") + }) + + svc := NewAggregateHealthService() + svc.AddProvider("db", provider) + + result, err := svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Health != StatusUnhealthy { + t.Errorf("expected unhealthy for permanent error, got %v", result.Health) + } +} + +func TestAggregateHealthService_EventEmission(t *testing.T) { + sub := &testSubject{} + svc := NewAggregateHealthService(WithSubject(sub)) + svc.AddProvider("db", NewStaticHealthProvider(HealthReport{ + Module: "db", Component: "conn", Status: StatusHealthy, + })) + + _, _ = svc.Check(context.Background()) + + events := sub.getEvents() + // First check: should emit evaluated + status changed (unknown -> healthy) + if len(events) < 1 { + t.Fatal("expected at least 1 event") + } + + hasEvaluated := false + hasChanged := false + for _, e := range events { + switch e.Type() { + case EventTypeHealthEvaluated: + hasEvaluated = true + case EventTypeHealthStatusChanged: + hasChanged = true + } + } + if !hasEvaluated { + t.Error("expected health evaluated event") + } + if !hasChanged { + t.Error("expected health status changed event (unknown -> healthy)") + } +} + +func TestAggregateHealthService_ConcurrentChecks(t *testing.T) { + svc := NewAggregateHealthService(WithCacheTTL(1 * time.Millisecond)) + svc.AddProvider("db", NewStaticHealthProvider(HealthReport{ + Module: "db", Component: "conn", Status: StatusHealthy, + })) + + const goroutines = 20 + var wg sync.WaitGroup + errs := make(chan error, goroutines) + + for range goroutines { + wg.Go(func() { + result, err := svc.Check(context.Background()) + if err != nil { + errs <- err + return + } + if result == nil { + errs <- errors.New("nil result") + return + } + }) + } + + wg.Wait() + close(errs) + + for err := range errs { + t.Errorf("concurrent check error: %v", err) + } +} diff --git a/implicit_dependency_bug_test.go b/implicit_dependency_bug_test.go index cc2451bd..50e8d6ee 100644 --- a/implicit_dependency_bug_test.go +++ b/implicit_dependency_bug_test.go @@ -88,7 +88,7 @@ func TestImplicitDependencyDeterministicFix(t *testing.T) { // This test will pass once we fix the dependency resolution to be deterministic attempts := 20 - for i := 0; i < attempts; i++ { + for i := range attempts { err := runSingleImplicitDependencyTestWithFix() if err != nil { t.Fatalf("Attempt %d failed after fix: %v", i+1, err) @@ -164,7 +164,7 @@ func TestNamingGameAttempt(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Test multiple times to ensure deterministic behavior - for attempt := 0; attempt < 5; attempt++ { + for attempt := range 5 { err := runNamingGameTest(tt.providerName, tt.consumerName) if err != nil { t.Errorf("Attempt %d failed for %s (%s): %v", @@ -253,7 +253,7 @@ func TestServiceNamingGameAttempt(t *testing.T) { } // Test multiple times to ensure deterministic behavior - for attempt := 0; attempt < 3; attempt++ { + for attempt := range 3 { err := runServiceNamingGameTest(tt.serviceName) if err != nil { t.Errorf("Attempt %d failed for %s (%s): %v", @@ -353,7 +353,7 @@ func (m *FlakyServerModule) RequiresServices() []ServiceDependency { Name: "router", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*http.Handler)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[http.Handler](), }, } } @@ -476,7 +476,7 @@ func (m *CustomServiceConsumerModule) RequiresServices() []ServiceDependency { Name: m.serviceName, Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*http.Handler)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[http.Handler](), }, } } diff --git a/instance_aware_comprehensive_regression_test.go b/instance_aware_comprehensive_regression_test.go index 5e09f5e2..6df997e9 100644 --- a/instance_aware_comprehensive_regression_test.go +++ b/instance_aware_comprehensive_regression_test.go @@ -5,7 +5,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // TestInstanceAwareComprehensiveRegressionSuite creates a comprehensive test suite @@ -347,8 +347,8 @@ func testRegressionDetectionCopyVsOriginal(t *testing.T) { // Create a "broken" version of GetInstanceConfigs that returns copies // This simulates what would happen if someone reverted the fix - brokenGetInstanceConfigs := func() map[string]interface{} { - instances := make(map[string]interface{}) + brokenGetInstanceConfigs := func() map[string]any { + instances := make(map[string]any) for name, connection := range config.Connections { // BUG: Creating a copy instead of returning pointer to original connectionCopy := *connection diff --git a/instance_aware_config.go b/instance_aware_config.go index 930236a9..36056af1 100644 --- a/instance_aware_config.go +++ b/instance_aware_config.go @@ -27,5 +27,5 @@ func (p *InstanceAwareConfigProvider) GetInstancePrefixFunc() InstancePrefixFunc // InstanceAwareConfigSupport indicates that a configuration supports instance-aware feeding type InstanceAwareConfigSupport interface { // GetInstanceConfigs returns a map of instance configurations that should be fed with instance-aware feeders - GetInstanceConfigs() map[string]interface{} + GetInstanceConfigs() map[string]any } diff --git a/instance_aware_feeding_test.go b/instance_aware_feeding_test.go index 35dd5d4a..5dd50f71 100644 --- a/instance_aware_feeding_test.go +++ b/instance_aware_feeding_test.go @@ -6,7 +6,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // TestInstanceAwareFeedingAfterYAML verifies instance-aware feeding after YAML load. @@ -280,8 +280,8 @@ func (c *TestDatabaseConfig) Validate() error { return nil } -func (c *TestDatabaseConfig) GetInstanceConfigs() map[string]interface{} { - instances := make(map[string]interface{}) +func (c *TestDatabaseConfig) GetInstanceConfigs() map[string]any { + instances := make(map[string]any) for name, connection := range c.Connections { instances[name] = connection } @@ -302,8 +302,8 @@ func (c *TestWebappConfig) Validate() error { return nil } -func (c *TestWebappConfig) GetInstanceConfigs() map[string]interface{} { - instances := make(map[string]interface{}) +func (c *TestWebappConfig) GetInstanceConfigs() map[string]any { + instances := make(map[string]any) for name, instance := range c.Instances { instances[name] = instance } @@ -433,7 +433,7 @@ func testWebappInstanceAwareFeedingResults(t *testing.T, provider ConfigProvider func splitKey(key string) []string { parts := make([]string, 0, 2) - for i := 0; i < 2; i++ { + for i := range 2 { if dotIndex := findDotIndex(key); dotIndex != -1 { if i == 0 { parts = append(parts, key[:dotIndex]) @@ -561,8 +561,8 @@ func (c *TestInstanceConfig) Validate() error { return nil } -func (c *TestInstanceConfig) GetInstanceConfigs() map[string]interface{} { - instances := make(map[string]interface{}) +func (c *TestInstanceConfig) GetInstanceConfigs() map[string]any { + instances := make(map[string]any) for name, item := range c.Items { instances[name] = item } diff --git a/interface_dependencies_test.go b/interface_dependencies_test.go index 5bf9cdc8..f3a5dff1 100644 --- a/interface_dependencies_test.go +++ b/interface_dependencies_test.go @@ -147,7 +147,7 @@ func (m *RouterConsumerModule) RequiresServices() []ServiceDependency { Name: "router", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*Router)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[Router](), }, } } diff --git a/interface_matching_test.go b/interface_matching_test.go index 295e4edb..1777f9de 100644 --- a/interface_matching_test.go +++ b/interface_matching_test.go @@ -353,7 +353,7 @@ func (m *InterfaceConsumerModule) RequiresServices() []ServiceDependency { Name: "router.service", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*handleFuncService)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[handleFuncService](), }, } } @@ -436,7 +436,7 @@ func (m *CustomNameConsumerModule) RequiresServices() []ServiceDependency { Name: "router", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*handleFuncService)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[handleFuncService](), }, } } diff --git a/issue_reproduction_test.go b/issue_reproduction_test.go index 1540a4f4..dcaf5491 100644 --- a/issue_reproduction_test.go +++ b/issue_reproduction_test.go @@ -4,7 +4,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // TestIssueReproduction demonstrates the exact scenario from the GitHub issue diff --git a/logger_decorator_assertions_bdd_test.go b/logger_decorator_assertions_bdd_test.go index 9d3b4d1b..a511d9fc 100644 --- a/logger_decorator_assertions_bdd_test.go +++ b/logger_decorator_assertions_bdd_test.go @@ -174,7 +174,7 @@ func (ctx *LoggerDecoratorBDDTestContext) theMessagesShouldHaveLevels(expectedLe func InitializeLoggerDecoratorScenario(ctx *godog.ScenarioContext) { testCtx := &LoggerDecoratorBDDTestContext{ expectedArgs: make(map[string]string), - filterCriteria: make(map[string]interface{}), + filterCriteria: make(map[string]any), levelMappings: make(map[string]string), } diff --git a/logger_decorator_base_bdd_test.go b/logger_decorator_base_bdd_test.go index ad6d90cd..4a0cec0c 100644 --- a/logger_decorator_base_bdd_test.go +++ b/logger_decorator_base_bdd_test.go @@ -14,10 +14,10 @@ var ( errSecondaryLoggerNotSet = errors.New("secondary logger not set") errDecoratedLoggerNotSet = errors.New("decorated logger not set") errNoMessagesLogged = errors.New("no messages logged") - errUnexpectedMessageCount = errors.New("unexpected message count") - errMessageNotFound = errors.New("message not found") - errArgNotFound = errors.New("argument not found") - errUnexpectedLogLevel = errors.New("unexpected log level") + _ = errors.New("unexpected message count") + _ = errors.New("message not found") + _ = errors.New("argument not found") + _ = errors.New("unexpected log level") errServiceLoggerMismatch = errors.New("service logger mismatch") ) @@ -33,7 +33,7 @@ type LoggerDecoratorBDDTestContext struct { currentLogger Logger expectedMessages []string expectedArgs map[string]string - filterCriteria map[string]interface{} + filterCriteria map[string]any levelMappings map[string]string messageCount int expectedLevels []string diff --git a/logger_test.go b/logger_test.go index ec5cf56b..34055461 100644 --- a/logger_test.go +++ b/logger_test.go @@ -9,18 +9,18 @@ type MockLogger struct { mock.Mock } -func (m *MockLogger) Debug(msg string, args ...interface{}) { +func (m *MockLogger) Debug(msg string, args ...any) { m.Called(msg, args) } -func (m *MockLogger) Info(msg string, args ...interface{}) { +func (m *MockLogger) Info(msg string, args ...any) { m.Called(msg, args) } -func (m *MockLogger) Warn(msg string, args ...interface{}) { +func (m *MockLogger) Warn(msg string, args ...any) { m.Called(msg, args) } -func (m *MockLogger) Error(msg string, args ...interface{}) { +func (m *MockLogger) Error(msg string, args ...any) { m.Called(msg, args) } diff --git a/metrics.go b/metrics.go new file mode 100644 index 00000000..d5e9fd07 --- /dev/null +++ b/metrics.go @@ -0,0 +1,14 @@ +package modular + +import "context" + +// ModuleMetrics holds metrics collected from a single module. +type ModuleMetrics struct { + Name string + Values map[string]float64 +} + +// MetricsProvider is an optional interface for modules that expose operational metrics. +type MetricsProvider interface { + CollectMetrics(ctx context.Context) ModuleMetrics +} diff --git a/metrics_test.go b/metrics_test.go new file mode 100644 index 00000000..561de7d0 --- /dev/null +++ b/metrics_test.go @@ -0,0 +1,74 @@ +package modular + +import ( + "context" + "testing" +) + +type metricsTestModule struct { + name string +} + +func (m *metricsTestModule) Name() string { return m.name } +func (m *metricsTestModule) Init(app Application) error { return nil } +func (m *metricsTestModule) CollectMetrics(ctx context.Context) ModuleMetrics { + return ModuleMetrics{ + Name: m.name, + Values: map[string]float64{"requests_total": 100, "error_rate": 0.02}, + } +} + +type nonMetricsModule struct { + name string +} + +func (m *nonMetricsModule) Name() string { return m.name } +func (m *nonMetricsModule) Init(app Application) error { return nil } + +func TestCollectAllMetrics(t *testing.T) { + modA := &metricsTestModule{name: "api"} + modB := &nonMetricsModule{name: "no-metrics"} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + stdApp := app.(*StdApplication) + metrics := stdApp.CollectAllMetrics(context.Background()) + + if len(metrics) != 1 { + t.Fatalf("expected 1 metrics result, got %d", len(metrics)) + } + if metrics[0].Name != "api" { + t.Errorf("expected api, got %s", metrics[0].Name) + } + if metrics[0].Values["requests_total"] != 100 { + t.Errorf("expected requests_total=100, got %v", metrics[0].Values["requests_total"]) + } +} + +func TestCollectAllMetrics_NoProviders(t *testing.T) { + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(&nonMetricsModule{name: "plain"}), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + stdApp := app.(*StdApplication) + metrics := stdApp.CollectAllMetrics(context.Background()) + if len(metrics) != 0 { + t.Errorf("expected 0 metrics, got %d", len(metrics)) + } +} diff --git a/module.go b/module.go index 506f4cd9..eed64041 100644 --- a/module.go +++ b/module.go @@ -16,7 +16,10 @@ // } package modular -import "context" +import ( + "context" + "time" +) // Module represents a registrable component in the application. // All modules must implement this interface to be managed by the application. @@ -248,6 +251,28 @@ type ModuleWithConstructor interface { Constructable } +// Reloadable is an optional interface for modules that support dynamic configuration reloading. +// Modules implementing this interface can have their configuration updated at runtime +// without requiring a full application restart. +// +// The reload process is coordinated by the ReloadOrchestrator, which detects configuration +// changes, computes diffs, and calls Reload on each module that supports it. +type Reloadable interface { + // Reload applies configuration changes to the module. + // The changes slice contains only the changes relevant to this module. + // Implementations should apply changes atomically where possible. + Reload(ctx context.Context, changes []ConfigChange) error + + // CanReload reports whether the module can currently accept a reload. + // Modules may return false if they are in a state where reloading is unsafe + // (e.g., mid-transaction, shutting down). + CanReload() bool + + // ReloadTimeout returns the maximum duration allowed for a reload operation. + // The orchestrator will cancel the reload context if this timeout is exceeded. + ReloadTimeout() time.Duration +} + // ModuleRegistry represents a registry of modules keyed by their names. // This is used internally by the application to manage registered modules // and resolve dependencies between them. diff --git a/module_aware_env_config_test.go b/module_aware_env_config_test.go index 8b748e06..e4096d07 100644 --- a/module_aware_env_config_test.go +++ b/module_aware_env_config_test.go @@ -291,7 +291,7 @@ func TestModuleAwareEnvironmentVariableSearching(t *testing.T) { // mockModuleAwareConfigModule is a mock module for testing module-aware configuration type mockModuleAwareConfigModule struct { name string - config interface{} + config any } func (m *mockModuleAwareConfigModule) Name() string { @@ -314,7 +314,7 @@ func (m *mockModuleAwareConfigModule) Init(app Application) error { } // createTestApplication creates a basic application for testing -func createTestApplication(t *testing.T) *StdApplication { +func createTestApplication(_ *testing.T) *StdApplication { logger := &simpleTestLogger{} app := NewStdApplication(nil, logger) return app.(*StdApplication) diff --git a/modules/README.md b/modules/README.md index cde2dc17..2560e141 100644 --- a/modules/README.md +++ b/modules/README.md @@ -2,24 +2,24 @@ This directory contains all the pre-built modules available in the Modular framework. Each module is designed to be plug-and-play, well-documented, and production-ready. -[![Modules CI](https://github.com/CrisisTextLine/modular/actions/workflows/modules-ci.yml/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/modules-ci.yml) +[![Modules CI](https://github.com/GoCodeAlone/modular/actions/workflows/modules-ci.yml/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/modules-ci.yml) ## 📋 Module Directory | Module | Description | Configuration | Dependencies | Go Docs | |----------------------------|------------------------------------------|---------------|----------------------------------------|---------| -| [auth](./auth) | Authentication and authorization with JWT, sessions, password hashing, and OAuth2/OIDC support | [Yes](./auth/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/auth.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/auth) | -| [cache](./cache) | Multi-backend caching with Redis and in-memory support | [Yes](./cache/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/cache.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/cache) | -| [chimux](./chimux) | Chi router integration with middleware support | [Yes](./chimux/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/chimux.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/chimux) | -| [database](./database) | Database connectivity and SQL operations with multiple driver support | [Yes](./database/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/database.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/database) | -| [eventbus](./eventbus) | Asynchronous event handling and pub/sub messaging | [Yes](./eventbus/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/eventbus.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/eventbus) | -| [httpclient](./httpclient) | Configurable HTTP client with connection pooling, timeouts, and verbose logging | [Yes](./httpclient/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/httpclient.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/httpclient) | -| [httpserver](./httpserver) | HTTP/HTTPS server with TLS support, graceful shutdown, and configurable timeouts | [Yes](./httpserver/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/httpserver.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/httpserver) | -| [jsonschema](./jsonschema) | JSON Schema validation services | No | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/jsonschema.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/jsonschema) | -| [letsencrypt](./letsencrypt) | SSL/TLS certificate automation with Let's Encrypt | [Yes](./letsencrypt/config.go) | Works with httpserver | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/letsencrypt.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/letsencrypt) | -| [logmasker](./logmasker) | Centralized log masking with configurable rules and MaskableValue interface | [Yes](./logmasker/module.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/logmasker.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/logmasker) | -| [reverseproxy](./reverseproxy) | Reverse proxy with load balancing, circuit breaker, and health monitoring | [Yes](./reverseproxy/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/reverseproxy.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/reverseproxy) | -| [scheduler](./scheduler) | Job scheduling with cron expressions and worker pools | [Yes](./scheduler/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/scheduler.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/scheduler) | +| [auth](./auth) | Authentication and authorization with JWT, sessions, password hashing, and OAuth2/OIDC support | [Yes](./auth/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/auth.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/auth) | +| [cache](./cache) | Multi-backend caching with Redis and in-memory support | [Yes](./cache/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/cache.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/cache) | +| [chimux](./chimux) | Chi router integration with middleware support | [Yes](./chimux/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/chimux.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/chimux) | +| [database](./database) | Database connectivity and SQL operations with multiple driver support | [Yes](./database/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/database.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/database) | +| [eventbus](./eventbus) | Asynchronous event handling and pub/sub messaging | [Yes](./eventbus/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/eventbus.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/eventbus) | +| [httpclient](./httpclient) | Configurable HTTP client with connection pooling, timeouts, and verbose logging | [Yes](./httpclient/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/httpclient.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/httpclient) | +| [httpserver](./httpserver) | HTTP/HTTPS server with TLS support, graceful shutdown, and configurable timeouts | [Yes](./httpserver/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/httpserver.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/httpserver) | +| [jsonschema](./jsonschema) | JSON Schema validation services | No | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/jsonschema.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/jsonschema) | +| [letsencrypt](./letsencrypt) | SSL/TLS certificate automation with Let's Encrypt | [Yes](./letsencrypt/config.go) | Works with httpserver | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/letsencrypt.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/letsencrypt) | +| [logmasker](./logmasker) | Centralized log masking with configurable rules and MaskableValue interface | [Yes](./logmasker/module.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/logmasker.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/logmasker) | +| [reverseproxy](./reverseproxy) | Reverse proxy with load balancing, circuit breaker, and health monitoring | [Yes](./reverseproxy/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/reverseproxy.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/reverseproxy) | +| [scheduler](./scheduler) | Job scheduling with cron expressions and worker pools | [Yes](./scheduler/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/scheduler.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/scheduler) | ## 🚀 Quick Start diff --git a/modules/auth/README.md b/modules/auth/README.md index 9f2ab5cb..f2e52a20 100644 --- a/modules/auth/README.md +++ b/modules/auth/README.md @@ -1,6 +1,6 @@ # Authentication Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/auth.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/auth) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/auth.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/auth) The Authentication module provides comprehensive authentication capabilities for the Modular framework, including JWT tokens, session management, password hashing, and OAuth2/OIDC integration. @@ -16,7 +16,7 @@ The Authentication module provides comprehensive authentication capabilities for ## Installation ```bash -go get github.com/CrisisTextLine/modular/modules/auth +go get github.com/GoCodeAlone/modular/modules/auth ``` ## Configuration @@ -71,8 +71,8 @@ auth: package main import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/auth" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/auth" ) func main() { diff --git a/modules/auth/bdd_core_test.go b/modules/auth/bdd_core_test.go index 3e085ae0..a96f1550 100644 --- a/modules/auth/bdd_core_test.go +++ b/modules/auth/bdd_core_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/cucumber/godog" ) diff --git a/modules/auth/bdd_events_test.go b/modules/auth/bdd_events_test.go index 58411de2..94f86656 100644 --- a/modules/auth/bdd_events_test.go +++ b/modules/auth/bdd_events_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/cucumber/godog" "github.com/golang-jwt/jwt/v5" diff --git a/modules/auth/go.mod b/modules/auth/go.mod index 1997f0f9..9d6d81ef 100644 --- a/modules/auth/go.mod +++ b/modules/auth/go.mod @@ -1,9 +1,9 @@ -module github.com/CrisisTextLine/modular/modules/auth +module github.com/GoCodeAlone/modular/modules/auth go 1.25 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/golang-jwt/jwt/v5 v5.2.3 diff --git a/modules/auth/go.sum b/modules/auth/go.sum index 03b8430b..f71de52b 100644 --- a/modules/auth/go.sum +++ b/modules/auth/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= +github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/auth/module.go b/modules/auth/module.go index 1ac65b7c..75079f9b 100644 --- a/modules/auth/module.go +++ b/modules/auth/module.go @@ -25,7 +25,7 @@ import ( "context" "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/auth/module_test.go b/modules/auth/module_test.go index 9f4f3f47..bbec659a 100644 --- a/modules/auth/module_test.go +++ b/modules/auth/module_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/auth/service.go b/modules/auth/service.go index a69c70c7..fee6805e 100644 --- a/modules/auth/service.go +++ b/modules/auth/service.go @@ -13,7 +13,7 @@ import ( "time" "unicode" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" diff --git a/modules/cache/README.md b/modules/cache/README.md index 498e87a4..5b5e604e 100644 --- a/modules/cache/README.md +++ b/modules/cache/README.md @@ -1,6 +1,6 @@ # Cache Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/cache.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/cache) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/cache.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/cache) The Cache Module provides caching functionality for Modular applications. It offers different cache backend options including in-memory and Redis (placeholder implementation). @@ -16,8 +16,8 @@ The Cache Module provides caching functionality for Modular applications. It off ```go import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/cache" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/cache" ) // Register the cache module with your Modular application diff --git a/modules/cache/bdd_configuration_test.go b/modules/cache/bdd_configuration_test.go index 82a8b394..d8c827de 100644 --- a/modules/cache/bdd_configuration_test.go +++ b/modules/cache/bdd_configuration_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Configuration-related BDD test steps diff --git a/modules/cache/bdd_core_test.go b/modules/cache/bdd_core_test.go index 35be5254..6fca3596 100644 --- a/modules/cache/bdd_core_test.go +++ b/modules/cache/bdd_core_test.go @@ -6,7 +6,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/cache/bdd_event_errors_test.go b/modules/cache/bdd_event_errors_test.go index 49f4f97e..6e416b3e 100644 --- a/modules/cache/bdd_event_errors_test.go +++ b/modules/cache/bdd_event_errors_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Event observation BDD test steps for error scenarios diff --git a/modules/cache/bdd_event_eviction_test.go b/modules/cache/bdd_event_eviction_test.go index f5d56ee2..87355716 100644 --- a/modules/cache/bdd_event_eviction_test.go +++ b/modules/cache/bdd_event_eviction_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/cache/bdd_event_operations_test.go b/modules/cache/bdd_event_operations_test.go index b558610a..cc830e62 100644 --- a/modules/cache/bdd_event_operations_test.go +++ b/modules/cache/bdd_event_operations_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Event observation BDD test steps for basic operations diff --git a/modules/cache/go.mod b/modules/cache/go.mod index d6cb98fd..a50ad008 100644 --- a/modules/cache/go.mod +++ b/modules/cache/go.mod @@ -1,11 +1,11 @@ -module github.com/CrisisTextLine/modular/modules/cache +module github.com/GoCodeAlone/modular/modules/cache go 1.25 toolchain go1.25.0 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.0 github.com/alicebob/miniredis/v2 v2.35.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 diff --git a/modules/cache/go.sum b/modules/cache/go.sum index db0d10cf..80111e84 100644 --- a/modules/cache/go.sum +++ b/modules/cache/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= +github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= diff --git a/modules/cache/memory.go b/modules/cache/memory.go index 428174c9..b5456f72 100644 --- a/modules/cache/memory.go +++ b/modules/cache/memory.go @@ -5,7 +5,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/cache/module.go b/modules/cache/module.go index 6bd95e46..6c2d240a 100644 --- a/modules/cache/module.go +++ b/modules/cache/module.go @@ -69,7 +69,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/cache/module_test.go b/modules/cache/module_test.go index 0fb15120..cfe4303b 100644 --- a/modules/cache/module_test.go +++ b/modules/cache/module_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/alicebob/miniredis/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/modules/chimux/README.md b/modules/chimux/README.md index c650d59b..fb6c87d6 100644 --- a/modules/chimux/README.md +++ b/modules/chimux/README.md @@ -1,8 +1,8 @@ # chimux Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/chimux.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/chimux) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/chimux.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/chimux) -A module for the [Modular](https://github.com/CrisisTextLine/modular) framework. +A module for the [Modular](https://github.com/GoCodeAlone/modular) framework. ## Overview @@ -22,7 +22,7 @@ The chimux module provides a powerful HTTP router and middleware system for Modu ## Installation ```go -go get github.com/CrisisTextLine/modular/modules/chimux@v1.0.0 +go get github.com/GoCodeAlone/modular/modules/chimux@v1.0.0 ``` ## Usage @@ -31,8 +31,8 @@ go get github.com/CrisisTextLine/modular/modules/chimux@v1.0.0 package main import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/chimux" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/chimux" "log/slog" "net/http" "os" diff --git a/modules/chimux/bdd_config_test.go b/modules/chimux/bdd_config_test.go index 09b657c8..0d51354e 100644 --- a/modules/chimux/bdd_config_test.go +++ b/modules/chimux/bdd_config_test.go @@ -6,7 +6,7 @@ import ( "net/http" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Static errors for bdd_config_test.go diff --git a/modules/chimux/bdd_core_test.go b/modules/chimux/bdd_core_test.go index 239e3d14..1562993e 100644 --- a/modules/chimux/bdd_core_test.go +++ b/modules/chimux/bdd_core_test.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/chimux/bdd_cors_test.go b/modules/chimux/bdd_cors_test.go index 40205674..fc40b244 100644 --- a/modules/chimux/bdd_cors_test.go +++ b/modules/chimux/bdd_cors_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Static errors for bdd_cors_test.go diff --git a/modules/chimux/bdd_events_test.go b/modules/chimux/bdd_events_test.go index d8889dd8..5905817b 100644 --- a/modules/chimux/bdd_events_test.go +++ b/modules/chimux/bdd_events_test.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Static errors for bdd_events_test.go diff --git a/modules/chimux/chimux_race_test.go b/modules/chimux/chimux_race_test.go index 50434e33..21713f7a 100644 --- a/modules/chimux/chimux_race_test.go +++ b/modules/chimux/chimux_race_test.go @@ -3,8 +3,8 @@ package chimux_test import ( "testing" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/chimux" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/chimux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/chimux/go.mod b/modules/chimux/go.mod index 4079fdb8..a97a8340 100644 --- a/modules/chimux/go.mod +++ b/modules/chimux/go.mod @@ -1,9 +1,9 @@ -module github.com/CrisisTextLine/modular/modules/chimux +module github.com/GoCodeAlone/modular/modules/chimux go 1.25 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/go-chi/chi/v5 v5.2.2 diff --git a/modules/chimux/go.sum b/modules/chimux/go.sum index 6e04ae39..10db6514 100644 --- a/modules/chimux/go.sum +++ b/modules/chimux/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= +github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/chimux/mock_test.go b/modules/chimux/mock_test.go index 16958948..ad449f27 100644 --- a/modules/chimux/mock_test.go +++ b/modules/chimux/mock_test.go @@ -7,7 +7,7 @@ import ( "reflect" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/chimux/module.go b/modules/chimux/module.go index 9875bf2b..61616947 100644 --- a/modules/chimux/module.go +++ b/modules/chimux/module.go @@ -94,7 +94,7 @@ import ( "sync/atomic" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" diff --git a/modules/chimux/module_test.go b/modules/chimux/module_test.go index 0e0b227d..99bb813d 100644 --- a/modules/chimux/module_test.go +++ b/modules/chimux/module_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/modules/configwatcher/configwatcher.go b/modules/configwatcher/configwatcher.go new file mode 100644 index 00000000..2b7bc2b8 --- /dev/null +++ b/modules/configwatcher/configwatcher.go @@ -0,0 +1,142 @@ +package configwatcher + +import ( + "context" + "sync" + "time" + + "github.com/GoCodeAlone/modular" + "github.com/fsnotify/fsnotify" +) + +// ConfigWatcher watches config files and calls OnChange when modifications are detected. +type ConfigWatcher struct { + paths []string + debounce time.Duration + onChange func(paths []string) + watcher *fsnotify.Watcher + stopCh chan struct{} + stopOnce sync.Once +} + +// Option configures a ConfigWatcher. +type Option func(*ConfigWatcher) + +func WithPaths(paths ...string) Option { + return func(w *ConfigWatcher) { w.paths = append(w.paths, paths...) } +} + +func WithDebounce(d time.Duration) Option { + return func(w *ConfigWatcher) { w.debounce = d } +} + +func WithOnChange(fn func(paths []string)) Option { + return func(w *ConfigWatcher) { w.onChange = fn } +} + +func New(opts ...Option) *ConfigWatcher { + w := &ConfigWatcher{ + debounce: 500 * time.Millisecond, + stopCh: make(chan struct{}), + } + for _, opt := range opts { + opt(w) + } + return w +} + +func (w *ConfigWatcher) Name() string { return "configwatcher" } + +// Init satisfies the modular.Module interface. No-op since configuration is +// provided via functional options at construction time. +func (w *ConfigWatcher) Init(_ modular.Application) error { + return nil +} + +func (w *ConfigWatcher) Start(ctx context.Context) error { + if err := w.startWatching(); err != nil { + return err + } + go func() { + select { + case <-ctx.Done(): + w.stopWatching() + case <-w.stopCh: + } + }() + return nil +} + +func (w *ConfigWatcher) Stop(_ context.Context) error { + w.stopWatching() + return nil +} + +func (w *ConfigWatcher) startWatching() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + w.watcher = watcher + for _, path := range w.paths { + if err := watcher.Add(path); err != nil { + watcher.Close() + return err + } + } + go w.eventLoop() + return nil +} + +func (w *ConfigWatcher) stopWatching() { + w.stopOnce.Do(func() { + close(w.stopCh) + if w.watcher != nil { + w.watcher.Close() + } + }) +} + +func (w *ConfigWatcher) eventLoop() { + var timer *time.Timer + changedPaths := make(map[string]struct{}) + var mu sync.Mutex + + for { + select { + case event, ok := <-w.watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { + mu.Lock() + changedPaths[event.Name] = struct{}{} + if timer != nil { + timer.Stop() + } + timer = time.AfterFunc(w.debounce, func() { + if w.onChange != nil { + mu.Lock() + paths := make([]string, 0, len(changedPaths)) + for p := range changedPaths { + paths = append(paths, p) + } + changedPaths = make(map[string]struct{}) + mu.Unlock() + w.onChange(paths) + } + }) + mu.Unlock() + } + case _, ok := <-w.watcher.Errors: + if !ok { + return + } + case <-w.stopCh: + if timer != nil { + timer.Stop() + } + return + } + } +} diff --git a/modules/configwatcher/configwatcher_test.go b/modules/configwatcher/configwatcher_test.go new file mode 100644 index 00000000..0990b45a --- /dev/null +++ b/modules/configwatcher/configwatcher_test.go @@ -0,0 +1,73 @@ +package configwatcher + +import ( + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestConfigWatcher_DetectsFileChange(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgFile, []byte("key: value1"), 0644); err != nil { + t.Fatal(err) + } + + var changeCount atomic.Int32 + w := New( + WithPaths(cfgFile), + WithDebounce(50*time.Millisecond), + WithOnChange(func(paths []string) { changeCount.Add(1) }), + ) + + if err := w.startWatching(); err != nil { + t.Fatalf("startWatching: %v", err) + } + defer w.stopWatching() + + time.Sleep(100 * time.Millisecond) + if err := os.WriteFile(cfgFile, []byte("key: value2"), 0644); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if changeCount.Load() > 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + if changeCount.Load() == 0 { + t.Error("expected at least one change notification") + } +} + +func TestConfigWatcher_Debounces(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + os.WriteFile(cfgFile, []byte("v1"), 0644) + + var changeCount atomic.Int32 + w := New( + WithPaths(cfgFile), + WithDebounce(200*time.Millisecond), + WithOnChange(func(paths []string) { changeCount.Add(1) }), + ) + if err := w.startWatching(); err != nil { + t.Fatalf("startWatching: %v", err) + } + defer w.stopWatching() + + time.Sleep(100 * time.Millisecond) + for i := range 5 { + os.WriteFile(cfgFile, []byte("v"+string(rune('2'+i))), 0644) + time.Sleep(20 * time.Millisecond) + } + time.Sleep(500 * time.Millisecond) + + if changeCount.Load() > 2 { + t.Errorf("expected debounced to ~1-2 calls, got %d", changeCount.Load()) + } +} diff --git a/modules/database/IAM_TOKEN_ROTATION_FIX.md b/modules/database/IAM_TOKEN_ROTATION_FIX.md index 37c6c6d3..2261780a 100644 --- a/modules/database/IAM_TOKEN_ROTATION_FIX.md +++ b/modules/database/IAM_TOKEN_ROTATION_FIX.md @@ -159,7 +159,7 @@ $ go test -v -run "TestTTLStore" --- PASS: TestTTLStore_RealWorldScenario (3.01s) PASS -ok github.com/CrisisTextLine/modular/modules/database/v2 3.631s +ok github.com/GoCodeAlone/modular/modules/database/v2 3.631s ``` ### What The Tests Prove diff --git a/modules/database/README.md b/modules/database/README.md index 408086d0..4951bea7 100644 --- a/modules/database/README.md +++ b/modules/database/README.md @@ -1,9 +1,9 @@ # Database Module for Modular -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/database.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/database) -[![Modules CI](https://github.com/CrisisTextLine/modular/actions/workflows/modules-ci.yml/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/modules-ci.yml) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/database.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/database) +[![Modules CI](https://github.com/GoCodeAlone/modular/actions/workflows/modules-ci.yml/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/modules-ci.yml) -A [Modular](https://github.com/CrisisTextLine/modular) module that provides database connectivity and management. +A [Modular](https://github.com/GoCodeAlone/modular) module that provides database connectivity and management. ## Overview @@ -20,7 +20,7 @@ The Database module provides a service for connecting to and interacting with SQ ## Installation ```bash -go get github.com/CrisisTextLine/modular/modules/database +go get github.com/GoCodeAlone/modular/modules/database ``` ## Usage @@ -31,8 +31,8 @@ The database module uses the standard Go `database/sql` package, which requires ```go import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/database" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/database" // Import database drivers as needed _ "github.com/lib/pq" // PostgreSQL driver @@ -58,8 +58,8 @@ go get github.com/mattn/go-sqlite3 ```go import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/database" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/database" _ "github.com/lib/pq" // Import PostgreSQL driver ) diff --git a/modules/database/bdd_basic_operations_test.go b/modules/database/bdd_basic_operations_test.go index ba9d2256..96310a53 100644 --- a/modules/database/bdd_basic_operations_test.go +++ b/modules/database/bdd_basic_operations_test.go @@ -5,7 +5,7 @@ import ( "fmt" "os" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Module initialization and basic database operations diff --git a/modules/database/bdd_core_test.go b/modules/database/bdd_core_test.go index 6cb0c132..bdad01a8 100644 --- a/modules/database/bdd_core_test.go +++ b/modules/database/bdd_core_test.go @@ -6,7 +6,7 @@ import ( "sync" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/cucumber/godog" _ "modernc.org/sqlite" // Import pure-Go SQLite driver for BDD tests (works with CGO_DISABLED) diff --git a/modules/database/bdd_events_test.go b/modules/database/bdd_events_test.go index f61599fe..b9d41318 100644 --- a/modules/database/bdd_events_test.go +++ b/modules/database/bdd_events_test.go @@ -6,7 +6,7 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Event observation and emission functionality diff --git a/modules/database/config_env_test.go b/modules/database/config_env_test.go index 2ec45b1a..dfc4b01c 100644 --- a/modules/database/config_env_test.go +++ b/modules/database/config_env_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestConnectionConfigEnvMapping tests environment variable mapping for database connections diff --git a/modules/database/config_test.go b/modules/database/config_test.go index c15e22c0..1b55c1e4 100644 --- a/modules/database/config_test.go +++ b/modules/database/config_test.go @@ -3,7 +3,7 @@ package database import ( "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestGetInstanceConfigs_ReturnsOriginalPointers tests that GetInstanceConfigs returns diff --git a/modules/database/credential_refresh_store.go b/modules/database/credential_refresh_store.go index 60c02c90..5325d60a 100644 --- a/modules/database/credential_refresh_store.go +++ b/modules/database/credential_refresh_store.go @@ -7,7 +7,7 @@ import ( "fmt" "strings" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/aws/aws-sdk-go-v2/config" "github.com/davepgreene/go-db-credential-refresh/driver" "github.com/davepgreene/go-db-credential-refresh/store/awsrds" diff --git a/modules/database/db_test.go b/modules/database/db_test.go index 935e4a20..1155b513 100644 --- a/modules/database/db_test.go +++ b/modules/database/db_test.go @@ -10,9 +10,9 @@ import ( "reflect" "testing" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/database/v2" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/database/v2" _ "modernc.org/sqlite" // Import pure Go SQLite driver ) diff --git a/modules/database/go.mod b/modules/database/go.mod index a8b7fd94..3bd4fb6d 100644 --- a/modules/database/go.mod +++ b/modules/database/go.mod @@ -1,9 +1,9 @@ -module github.com/CrisisTextLine/modular/modules/database/v2 +module github.com/GoCodeAlone/modular/modules/database/v2 go 1.25 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.0 github.com/aws/aws-sdk-go-v2/config v1.31.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 diff --git a/modules/database/go.sum b/modules/database/go.sum index 336daa0b..09757702 100644 --- a/modules/database/go.sum +++ b/modules/database/go.sum @@ -5,8 +5,8 @@ filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= +github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= diff --git a/modules/database/integration_test.go b/modules/database/integration_test.go index f39a4ef7..e6280f8a 100644 --- a/modules/database/integration_test.go +++ b/modules/database/integration_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestDatabaseModuleWithInstanceAwareConfiguration tests the module with instance-aware env configuration diff --git a/modules/database/interface_matching_test.go b/modules/database/interface_matching_test.go index bd867a26..5d11ceff 100644 --- a/modules/database/interface_matching_test.go +++ b/modules/database/interface_matching_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" diff --git a/modules/database/migrations.go b/modules/database/migrations.go index f4fa4b45..83404f36 100644 --- a/modules/database/migrations.go +++ b/modules/database/migrations.go @@ -8,7 +8,7 @@ import ( "sort" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/database/module.go b/modules/database/module.go index 7be805c2..16c13724 100644 --- a/modules/database/module.go +++ b/modules/database/module.go @@ -32,7 +32,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/database/module_test.go b/modules/database/module_test.go index 62117a8c..9a01d383 100644 --- a/modules/database/module_test.go +++ b/modules/database/module_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" // Import pure Go sqlite driver for testing diff --git a/modules/database/service.go b/modules/database/service.go index c969a8c3..a7e2a1bc 100644 --- a/modules/database/service.go +++ b/modules/database/service.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Define static errors diff --git a/modules/eventbus/README.md b/modules/eventbus/README.md index 988b5cff..c772dbe3 100644 --- a/modules/eventbus/README.md +++ b/modules/eventbus/README.md @@ -1,6 +1,6 @@ # EventBus Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/eventbus.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/eventbus) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/eventbus.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/eventbus) The EventBus Module provides a publish-subscribe messaging system for Modular applications with support for multiple concurrent engines, topic-based routing, and flexible configuration. It enables decoupled communication between components through a powerful event-driven architecture. @@ -37,8 +37,8 @@ The EventBus Module provides a publish-subscribe messaging system for Modular ap ```go import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/eventbus" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/eventbus" ) // Register the eventbus module with your Modular application @@ -246,7 +246,7 @@ Register the collector with your Prometheus registry (global or custom): ```go import ( - "github.com/CrisisTextLine/modular/modules/eventbus" + "github.com/GoCodeAlone/modular/modules/eventbus" prom "github.com/prometheus/client_golang/prometheus" promhttp "github.com/prometheus/client_golang/prometheus/promhttp" "net/http" @@ -278,7 +278,7 @@ Start the exporter in a background goroutine. It periodically snapshots stats an ```go import ( "time" - "github.com/CrisisTextLine/modular/modules/eventbus" + "github.com/GoCodeAlone/modular/modules/eventbus" ) exporter, err := eventbus.NewDatadogStatsdExporter(eventBus, eventbus.DatadogExporterConfig{ diff --git a/modules/eventbus/bdd_context_test.go b/modules/eventbus/bdd_context_test.go index ba03a5c2..04551f67 100644 --- a/modules/eventbus/bdd_context_test.go +++ b/modules/eventbus/bdd_context_test.go @@ -4,7 +4,7 @@ import ( "context" "sync" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" cevent "github.com/cloudevents/sdk-go/v2/event" "github.com/google/uuid" diff --git a/modules/eventbus/bdd_core_initialization_test.go b/modules/eventbus/bdd_core_initialization_test.go index 48ee64e0..6d0afb86 100644 --- a/modules/eventbus/bdd_core_initialization_test.go +++ b/modules/eventbus/bdd_core_initialization_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // ============================================================================== diff --git a/modules/eventbus/bdd_engine_error_test.go b/modules/eventbus/bdd_engine_error_test.go index d8e3f201..54af40c3 100644 --- a/modules/eventbus/bdd_engine_error_test.go +++ b/modules/eventbus/bdd_engine_error_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // ============================================================================== diff --git a/modules/eventbus/bdd_multi_engine_test.go b/modules/eventbus/bdd_multi_engine_test.go index a639fb5a..f23fc2f4 100644 --- a/modules/eventbus/bdd_multi_engine_test.go +++ b/modules/eventbus/bdd_multi_engine_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // ============================================================================== diff --git a/modules/eventbus/bdd_registration_test.go b/modules/eventbus/bdd_registration_test.go index 5470e648..26074bd3 100644 --- a/modules/eventbus/bdd_registration_test.go +++ b/modules/eventbus/bdd_registration_test.go @@ -4,7 +4,7 @@ import ( "fmt" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/cucumber/godog" ) diff --git a/modules/eventbus/bdd_tenant_isolation_test.go b/modules/eventbus/bdd_tenant_isolation_test.go index 470d8099..05954582 100644 --- a/modules/eventbus/bdd_tenant_isolation_test.go +++ b/modules/eventbus/bdd_tenant_isolation_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // ============================================================================== diff --git a/modules/eventbus/concurrency_test.go b/modules/eventbus/concurrency_test.go index 7a89bb2c..06386e6e 100644 --- a/modules/eventbus/concurrency_test.go +++ b/modules/eventbus/concurrency_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Baseline stress test in drop mode to ensure no starvation of async subscribers. diff --git a/modules/eventbus/durable_memory_test.go b/modules/eventbus/durable_memory_test.go index 5bc71067..a0574834 100644 --- a/modules/eventbus/durable_memory_test.go +++ b/modules/eventbus/durable_memory_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/modules/eventbus/go.mod b/modules/eventbus/go.mod index 04a9d594..d48bcba9 100644 --- a/modules/eventbus/go.mod +++ b/modules/eventbus/go.mod @@ -1,13 +1,12 @@ -module github.com/CrisisTextLine/modular/modules/eventbus/v2 +module github.com/GoCodeAlone/modular/modules/eventbus/v2 go 1.25 toolchain go1.25.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/eventbus v1.7.0 github.com/DataDog/datadog-go/v5 v5.4.0 + github.com/GoCodeAlone/modular v1.12.0 github.com/IBM/sarama v1.45.2 github.com/aws/aws-sdk-go-v2/config v1.31.0 github.com/aws/aws-sdk-go-v2/service/kinesis v1.38.0 diff --git a/modules/eventbus/go.sum b/modules/eventbus/go.sum index e38aba0d..7c417cf5 100644 --- a/modules/eventbus/go.sum +++ b/modules/eventbus/go.sum @@ -1,11 +1,9 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= -github.com/CrisisTextLine/modular/modules/eventbus v1.7.0 h1:SSeu7rjuECDgFa+iNyndn94YPQxffHxJgfR7U4psz6E= -github.com/CrisisTextLine/modular/modules/eventbus v1.7.0/go.mod h1:I1tGf3DmadwyMP2NE2m6XHYl9ebXB9wBc/KZLywTR4c= github.com/DataDog/datadog-go/v5 v5.4.0 h1:Ea3eXUVwrVV28F/fo3Dr3aa+TL/Z7Xi6SUPKW8L99aI= github.com/DataDog/datadog-go/v5 v5.4.0/go.mod h1:K9kcYBlxkcPP8tvvjZZKs/m1edNAUFzBbdpTUKfCsuw= +github.com/GoCodeAlone/modular v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= +github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= github.com/IBM/sarama v1.45.2 h1:8m8LcMCu3REcwpa7fCP6v2fuPuzVwXDAM2DOv3CBrKw= github.com/IBM/sarama v1.45.2/go.mod h1:ppaoTcVdGv186/z6MEKsMm70A5fwJfRTpstI37kVn3Y= github.com/Microsoft/go-winio v0.5.0 h1:Elr9Wn+sGKPlkaBvwu4mTrxtmOp3F3yV9qhaHbXGjwU= diff --git a/modules/eventbus/kafka_test.go b/modules/eventbus/kafka_test.go index 42838da3..7c08cda3 100644 --- a/modules/eventbus/kafka_test.go +++ b/modules/eventbus/kafka_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "github.com/CrisisTextLine/modular/modules/eventbus/mocks" + "github.com/GoCodeAlone/modular/modules/eventbus/v2/mocks" ) // newTestKafkaEventBus creates a KafkaEventBus wired to a mock producer, diff --git a/modules/eventbus/kinesis.go b/modules/eventbus/kinesis.go index b5b21e73..ba7a1445 100644 --- a/modules/eventbus/kinesis.go +++ b/modules/eventbus/kinesis.go @@ -1,6 +1,6 @@ package eventbus -//go:generate mockgen -destination=mocks/mock_kinesis.go -package=mocks github.com/CrisisTextLine/modular/modules/eventbus KinesisClient +//go:generate mockgen -destination=mocks/mock_kinesis.go -package=mocks github.com/GoCodeAlone/modular/modules/eventbus KinesisClient import ( "context" diff --git a/modules/eventbus/kinesis_test.go b/modules/eventbus/kinesis_test.go index 2694627b..69e93d9c 100644 --- a/modules/eventbus/kinesis_test.go +++ b/modules/eventbus/kinesis_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "github.com/CrisisTextLine/modular/modules/eventbus/mocks" + "github.com/GoCodeAlone/modular/modules/eventbus/v2/mocks" ) // newTestKinesisEventBus creates a KinesisEventBus wired to a mock client, diff --git a/modules/eventbus/memory.go b/modules/eventbus/memory.go index 2da27898..34d344f2 100644 --- a/modules/eventbus/memory.go +++ b/modules/eventbus/memory.go @@ -7,7 +7,7 @@ import ( "sync/atomic" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/google/uuid" ) diff --git a/modules/eventbus/memory_buffer_test.go b/modules/eventbus/memory_buffer_test.go index 74baa497..a8038a7f 100644 --- a/modules/eventbus/memory_buffer_test.go +++ b/modules/eventbus/memory_buffer_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/modules/eventbus/memory_race_test.go b/modules/eventbus/memory_race_test.go index d9fcab21..c9ec18fd 100644 --- a/modules/eventbus/memory_race_test.go +++ b/modules/eventbus/memory_race_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestMemoryEventBusHighConcurrencyRace is a stress test intended to be run with -race. diff --git a/modules/eventbus/metrics_exporters_datadog_test.go b/modules/eventbus/metrics_exporters_datadog_test.go index 0c6ce427..e364e4e5 100644 --- a/modules/eventbus/metrics_exporters_datadog_test.go +++ b/modules/eventbus/metrics_exporters_datadog_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestDatadogStatsdExporterBasic spins up an in-process UDP listener to capture diff --git a/modules/eventbus/mocks/mock_kinesis.go b/modules/eventbus/mocks/mock_kinesis.go index e70e9ad3..a2441461 100644 --- a/modules/eventbus/mocks/mock_kinesis.go +++ b/modules/eventbus/mocks/mock_kinesis.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/CrisisTextLine/modular/modules/eventbus (interfaces: KinesisClient) +// Source: github.com/GoCodeAlone/modular/modules/eventbus (interfaces: KinesisClient) // // Generated by this command: // -// mockgen -destination=mocks/mock_kinesis.go -package=mocks github.com/CrisisTextLine/modular/modules/eventbus KinesisClient +// mockgen -destination=mocks/mock_kinesis.go -package=mocks github.com/GoCodeAlone/modular/modules/eventbus KinesisClient // // Package mocks is a generated GoMock package. diff --git a/modules/eventbus/module.go b/modules/eventbus/module.go index 9edfbc44..813c34fd 100644 --- a/modules/eventbus/module.go +++ b/modules/eventbus/module.go @@ -113,7 +113,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" cevent "github.com/cloudevents/sdk-go/v2/event" "github.com/google/uuid" diff --git a/modules/eventbus/module_test.go b/modules/eventbus/module_test.go index 07ed7a80..76924a05 100644 --- a/modules/eventbus/module_test.go +++ b/modules/eventbus/module_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/eventbus/publish_options_test.go b/modules/eventbus/publish_options_test.go index 6850f31d..ba219210 100644 --- a/modules/eventbus/publish_options_test.go +++ b/modules/eventbus/publish_options_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/eventlogger/README.md b/modules/eventlogger/README.md index f1e21a68..781914d7 100644 --- a/modules/eventlogger/README.md +++ b/modules/eventlogger/README.md @@ -1,6 +1,6 @@ # EventLogger Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/eventlogger.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/eventlogger) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/eventlogger.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/eventlogger) The EventLogger Module provides structured logging capabilities for Observer pattern events in Modular applications. It acts as an Observer that can be registered with any Subject to log events to various output targets including console, files, and syslog. @@ -19,8 +19,8 @@ The EventLogger Module provides structured logging capabilities for Observer pat ```go import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/eventlogger" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/eventlogger" ) // Register the eventlogger module with your Modular application @@ -83,8 +83,8 @@ eventlogger: ```go import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/eventlogger" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/eventlogger" ) func main() { diff --git a/modules/eventlogger/bdd_buffer_management_test.go b/modules/eventlogger/bdd_buffer_management_test.go index a5db5281..04eca074 100644 --- a/modules/eventlogger/bdd_buffer_management_test.go +++ b/modules/eventlogger/bdd_buffer_management_test.go @@ -7,7 +7,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/eventlogger/bdd_core_eventlogger_test.go b/modules/eventlogger/bdd_core_eventlogger_test.go index 48e6a709..e96dd6a2 100644 --- a/modules/eventlogger/bdd_core_eventlogger_test.go +++ b/modules/eventlogger/bdd_core_eventlogger_test.go @@ -6,7 +6,7 @@ import ( "path/filepath" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Configuration creation helpers diff --git a/modules/eventlogger/bdd_error_handling_test.go b/modules/eventlogger/bdd_error_handling_test.go index fc8a7c8d..80c87e97 100644 --- a/modules/eventlogger/bdd_error_handling_test.go +++ b/modules/eventlogger/bdd_error_handling_test.go @@ -6,7 +6,7 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Error handling step implementations diff --git a/modules/eventlogger/bdd_event_observation_test.go b/modules/eventlogger/bdd_event_observation_test.go index 24c4adf4..df12467f 100644 --- a/modules/eventlogger/bdd_event_observation_test.go +++ b/modules/eventlogger/bdd_event_observation_test.go @@ -5,7 +5,7 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Event observation setup and step implementations diff --git a/modules/eventlogger/bdd_test_shared_test.go b/modules/eventlogger/bdd_test_shared_test.go index f158862b..06feb891 100644 --- a/modules/eventlogger/bdd_test_shared_test.go +++ b/modules/eventlogger/bdd_test_shared_test.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/eventlogger/blacklist_filtering_test.go b/modules/eventlogger/blacklist_filtering_test.go index 6ccbc413..f6cc1ddd 100644 --- a/modules/eventlogger/blacklist_filtering_test.go +++ b/modules/eventlogger/blacklist_filtering_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/modules/eventlogger/go.mod b/modules/eventlogger/go.mod index e67fa718..1cf6ea18 100644 --- a/modules/eventlogger/go.mod +++ b/modules/eventlogger/go.mod @@ -1,11 +1,11 @@ -module github.com/CrisisTextLine/modular/modules/eventlogger +module github.com/GoCodeAlone/modular/modules/eventlogger go 1.25 toolchain go1.25.0 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/stretchr/testify v1.11.1 diff --git a/modules/eventlogger/go.sum b/modules/eventlogger/go.sum index b8bc6967..aac52522 100644 --- a/modules/eventlogger/go.sum +++ b/modules/eventlogger/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= +github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/eventlogger/module.go b/modules/eventlogger/module.go index c365c666..b8434926 100644 --- a/modules/eventlogger/module.go +++ b/modules/eventlogger/module.go @@ -120,7 +120,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/eventlogger/module_test.go b/modules/eventlogger/module_test.go index bfc022fb..d49dce71 100644 --- a/modules/eventlogger/module_test.go +++ b/modules/eventlogger/module_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/eventlogger/output.go b/modules/eventlogger/output.go index 1ef64b3f..e7467c6f 100644 --- a/modules/eventlogger/output.go +++ b/modules/eventlogger/output.go @@ -9,7 +9,7 @@ import ( "path/filepath" "strings" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // OutputTarget defines the interface for event log output targets. diff --git a/modules/eventlogger/race_condition_test.go b/modules/eventlogger/race_condition_test.go index 28dab951..efd43190 100644 --- a/modules/eventlogger/race_condition_test.go +++ b/modules/eventlogger/race_condition_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/eventlogger/regression_test.go b/modules/eventlogger/regression_test.go index 8d563bce..84c15171 100644 --- a/modules/eventlogger/regression_test.go +++ b/modules/eventlogger/regression_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/eventlogger/syslog_output_stub.go b/modules/eventlogger/syslog_output_stub.go index f8a146b7..d4e009dd 100644 --- a/modules/eventlogger/syslog_output_stub.go +++ b/modules/eventlogger/syslog_output_stub.go @@ -6,7 +6,7 @@ import ( "context" "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // SyslogTarget stub for unsupported platforms. diff --git a/modules/eventlogger/syslog_output_unix.go b/modules/eventlogger/syslog_output_unix.go index a9171d9f..67e213e3 100644 --- a/modules/eventlogger/syslog_output_unix.go +++ b/modules/eventlogger/syslog_output_unix.go @@ -7,7 +7,7 @@ import ( "fmt" "log/syslog" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // SyslogTarget outputs events to syslog (supported on Unix-like systems). diff --git a/modules/httpclient/README.md b/modules/httpclient/README.md index 209d33bb..0f43ced3 100644 --- a/modules/httpclient/README.md +++ b/modules/httpclient/README.md @@ -1,6 +1,6 @@ # HTTP Client Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/httpclient.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/httpclient) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/httpclient.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/httpclient) This module provides a configurable HTTP client service that can be used by other modules in the modular framework. It supports configurable connection pooling, timeouts, and optional verbose logging of HTTP requests and responses. @@ -92,9 +92,9 @@ func (m *ReverseProxyModule) Constructor() modular.ModuleConstructor { package main import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/httpclient" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/httpclient" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) func main() { diff --git a/modules/httpclient/bdd_core_httpclient_test.go b/modules/httpclient/bdd_core_httpclient_test.go index 1607add8..a32e93a3 100644 --- a/modules/httpclient/bdd_core_httpclient_test.go +++ b/modules/httpclient/bdd_core_httpclient_test.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/httpclient/bdd_events_test.go b/modules/httpclient/bdd_events_test.go index 6e9796d6..b7d5196e 100644 --- a/modules/httpclient/bdd_events_test.go +++ b/modules/httpclient/bdd_events_test.go @@ -5,7 +5,7 @@ import ( "net/http" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Event Observation BDD Test Steps diff --git a/modules/httpclient/go.mod b/modules/httpclient/go.mod index a7724e4f..d26dec02 100644 --- a/modules/httpclient/go.mod +++ b/modules/httpclient/go.mod @@ -1,9 +1,9 @@ -module github.com/CrisisTextLine/modular/modules/httpclient +module github.com/GoCodeAlone/modular/modules/httpclient go 1.25 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/stretchr/testify v1.11.1 diff --git a/modules/httpclient/go.sum b/modules/httpclient/go.sum index b8bc6967..aac52522 100644 --- a/modules/httpclient/go.sum +++ b/modules/httpclient/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= +github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/httpclient/logger.go b/modules/httpclient/logger.go index a45c2d09..08a9cb4b 100644 --- a/modules/httpclient/logger.go +++ b/modules/httpclient/logger.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // sanitizeForFilename replaces unsafe filename characters and ensures no directory traversal or special segments are allowed. diff --git a/modules/httpclient/module.go b/modules/httpclient/module.go index 51567511..9846c270 100644 --- a/modules/httpclient/module.go +++ b/modules/httpclient/module.go @@ -126,7 +126,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/httpclient/module_test.go b/modules/httpclient/module_test.go index 7b54ea62..b95dce15 100644 --- a/modules/httpclient/module_test.go +++ b/modules/httpclient/module_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/modules/httpclient/service_dependency_test.go b/modules/httpclient/service_dependency_test.go index e3b60698..ddc8d837 100644 --- a/modules/httpclient/service_dependency_test.go +++ b/modules/httpclient/service_dependency_test.go @@ -5,7 +5,7 @@ import ( "reflect" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/httpserver/README.md b/modules/httpserver/README.md index 7bd2b25b..6c7e2fba 100644 --- a/modules/httpserver/README.md +++ b/modules/httpserver/README.md @@ -1,6 +1,6 @@ # HTTP Server Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/httpserver.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/httpserver) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/httpserver.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/httpserver) This module provides HTTP/HTTPS server capabilities for the modular framework. It handles listening on a specified port, TLS configuration, and server timeouts. @@ -44,10 +44,10 @@ This module works with other modules in the application: package main import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/chimux" - "github.com/CrisisTextLine/modular/modules/httpserver" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/chimux" + "github.com/GoCodeAlone/modular/modules/httpserver" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) func main() { diff --git a/modules/httpserver/bdd_core_httpserver_test.go b/modules/httpserver/bdd_core_httpserver_test.go index 8760431c..c958792e 100644 --- a/modules/httpserver/bdd_core_httpserver_test.go +++ b/modules/httpserver/bdd_core_httpserver_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/cucumber/godog" ) diff --git a/modules/httpserver/bdd_events_test.go b/modules/httpserver/bdd_events_test.go index 5836824b..15154d85 100644 --- a/modules/httpserver/bdd_events_test.go +++ b/modules/httpserver/bdd_events_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/cucumber/godog" ) diff --git a/modules/httpserver/certificate_service_test.go b/modules/httpserver/certificate_service_test.go index 6ae432af..37d61fee 100644 --- a/modules/httpserver/certificate_service_test.go +++ b/modules/httpserver/certificate_service_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // MockCertificateService implements CertificateService for testing diff --git a/modules/httpserver/go.mod b/modules/httpserver/go.mod index d690ca83..23ff3c87 100644 --- a/modules/httpserver/go.mod +++ b/modules/httpserver/go.mod @@ -1,9 +1,9 @@ -module github.com/CrisisTextLine/modular/modules/httpserver +module github.com/GoCodeAlone/modular/modules/httpserver go 1.25 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/stretchr/testify v1.11.1 diff --git a/modules/httpserver/go.sum b/modules/httpserver/go.sum index b8bc6967..aac52522 100644 --- a/modules/httpserver/go.sum +++ b/modules/httpserver/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= +github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/httpserver/module.go b/modules/httpserver/module.go index 3ef0c918..12968ae3 100644 --- a/modules/httpserver/module.go +++ b/modules/httpserver/module.go @@ -43,7 +43,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/httpserver/module_test.go b/modules/httpserver/module_test.go index dcd69e98..72092605 100644 --- a/modules/httpserver/module_test.go +++ b/modules/httpserver/module_test.go @@ -22,7 +22,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/modules/jsonschema/README.md b/modules/jsonschema/README.md index b05a1acf..db683786 100644 --- a/modules/jsonschema/README.md +++ b/modules/jsonschema/README.md @@ -1,9 +1,9 @@ # JSON Schema Module for Modular -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/jsonschema.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/jsonschema) -[![Modules CI](https://github.com/CrisisTextLine/modular/actions/workflows/modules-ci.yml/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/modules-ci.yml) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/jsonschema.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/jsonschema) +[![Modules CI](https://github.com/GoCodeAlone/modular/actions/workflows/modules-ci.yml/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/modules-ci.yml) -A [Modular](https://github.com/CrisisTextLine/modular) module that provides JSON Schema validation capabilities. +A [Modular](https://github.com/GoCodeAlone/modular) module that provides JSON Schema validation capabilities. ## Overview @@ -21,7 +21,7 @@ The JSON Schema module provides a service for validating JSON data against JSON ## Installation ```bash -go get github.com/CrisisTextLine/modular/modules/jsonschema@v1.0.0 +go get github.com/GoCodeAlone/modular/modules/jsonschema@v1.0.0 ``` ## Usage @@ -30,8 +30,8 @@ go get github.com/CrisisTextLine/modular/modules/jsonschema@v1.0.0 ```go import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/jsonschema" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/jsonschema" ) func main() { diff --git a/modules/jsonschema/bdd_event_handling_test.go b/modules/jsonschema/bdd_event_handling_test.go index 0c80ee47..a0142b77 100644 --- a/modules/jsonschema/bdd_event_handling_test.go +++ b/modules/jsonschema/bdd_event_handling_test.go @@ -4,7 +4,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // CloudEvents emission and handling step methods diff --git a/modules/jsonschema/bdd_module_initialization_test.go b/modules/jsonschema/bdd_module_initialization_test.go index 54e9c8c4..b4951b21 100644 --- a/modules/jsonschema/bdd_module_initialization_test.go +++ b/modules/jsonschema/bdd_module_initialization_test.go @@ -3,7 +3,7 @@ package jsonschema import ( "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Core module initialization and setup step methods diff --git a/modules/jsonschema/go.mod b/modules/jsonschema/go.mod index 6c09529e..a469e3ae 100644 --- a/modules/jsonschema/go.mod +++ b/modules/jsonschema/go.mod @@ -1,11 +1,11 @@ -module github.com/CrisisTextLine/modular/modules/jsonschema +module github.com/GoCodeAlone/modular/modules/jsonschema go 1.25 toolchain go1.25.0 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 diff --git a/modules/jsonschema/go.sum b/modules/jsonschema/go.sum index c2317154..5ce38311 100644 --- a/modules/jsonschema/go.sum +++ b/modules/jsonschema/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= +github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/jsonschema/jsonschema_module_bdd_test.go b/modules/jsonschema/jsonschema_module_bdd_test.go index 53e45e90..707699a6 100644 --- a/modules/jsonschema/jsonschema_module_bdd_test.go +++ b/modules/jsonschema/jsonschema_module_bdd_test.go @@ -5,7 +5,7 @@ import ( "sync" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/cucumber/godog" ) diff --git a/modules/jsonschema/module.go b/modules/jsonschema/module.go index bfdacd6f..5b9e92f5 100644 --- a/modules/jsonschema/module.go +++ b/modules/jsonschema/module.go @@ -146,7 +146,7 @@ import ( "context" "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/jsonschema/schema_test.go b/modules/jsonschema/schema_test.go index 0b88f6e4..2b947cd6 100644 --- a/modules/jsonschema/schema_test.go +++ b/modules/jsonschema/schema_test.go @@ -10,8 +10,8 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/jsonschema" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/jsonschema" ) // Define static error diff --git a/modules/jsonschema/service.go b/modules/jsonschema/service.go index aacbfd89..674a2aeb 100644 --- a/modules/jsonschema/service.go +++ b/modules/jsonschema/service.go @@ -6,7 +6,7 @@ import ( "fmt" "io" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/santhosh-tekuri/jsonschema/v6" ) diff --git a/modules/letsencrypt/README.md b/modules/letsencrypt/README.md index a1198300..b2a7aae0 100644 --- a/modules/letsencrypt/README.md +++ b/modules/letsencrypt/README.md @@ -2,7 +2,7 @@ The Let's Encrypt module provides automatic SSL/TLS certificate generation and management using Let's Encrypt's ACME protocol. It integrates seamlessly with the Modular framework to provide HTTPS capabilities for your applications. -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/letsencrypt.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/letsencrypt) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/letsencrypt.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/letsencrypt) ## Features @@ -17,7 +17,7 @@ The Let's Encrypt module provides automatic SSL/TLS certificate generation and m ## Installation ```bash -go get github.com/CrisisTextLine/modular/modules/letsencrypt +go get github.com/GoCodeAlone/modular/modules/letsencrypt ``` ## Quick Start @@ -32,9 +32,9 @@ import ( "log/slog" "os" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/letsencrypt" - "github.com/CrisisTextLine/modular/modules/httpserver" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/letsencrypt" + "github.com/GoCodeAlone/modular/modules/httpserver" ) type AppConfig struct { diff --git a/modules/letsencrypt/bdd_core_functionality_test.go b/modules/letsencrypt/bdd_core_functionality_test.go index be6f6fde..5f688933 100644 --- a/modules/letsencrypt/bdd_core_functionality_test.go +++ b/modules/letsencrypt/bdd_core_functionality_test.go @@ -8,7 +8,7 @@ import ( "strings" "sync" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/cucumber/godog" ) diff --git a/modules/letsencrypt/bdd_event_system_test.go b/modules/letsencrypt/bdd_event_system_test.go index 660e5036..19d31ae1 100644 --- a/modules/letsencrypt/bdd_event_system_test.go +++ b/modules/letsencrypt/bdd_event_system_test.go @@ -6,7 +6,7 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/cucumber/godog" ) diff --git a/modules/letsencrypt/go.mod b/modules/letsencrypt/go.mod index cf200d76..29f36c3b 100644 --- a/modules/letsencrypt/go.mod +++ b/modules/letsencrypt/go.mod @@ -1,10 +1,10 @@ -module github.com/CrisisTextLine/modular/modules/letsencrypt +module github.com/GoCodeAlone/modular/modules/letsencrypt go 1.25 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/httpserver v0.2.3 + github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular/modules/httpserver v1.12.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/go-acme/lego/v4 v4.26.0 diff --git a/modules/letsencrypt/go.sum b/modules/letsencrypt/go.sum index 4c53f022..0330822d 100644 --- a/modules/letsencrypt/go.sum +++ b/modules/letsencrypt/go.sum @@ -29,10 +29,10 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= -github.com/CrisisTextLine/modular/modules/httpserver v0.2.3 h1:SKAySbzMHnsNzggg3ntx+/aOqv+kRJME3zZzgKW4t18= -github.com/CrisisTextLine/modular/modules/httpserver v0.2.3/go.mod h1:lIVyUIIMyTYZI2sprVkmREh+8z7vbENTKCHKNlRou3I= +github.com/GoCodeAlone/modular v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= +github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= +github.com/GoCodeAlone/modular/modules/httpserver v1.12.0 h1:nVaeiC59OEqMj0jcDZwIUHrba4CdPT9ntcGBAw81iKs= +github.com/GoCodeAlone/modular/modules/httpserver v1.12.0/go.mod h1:sVklMEsxKxKihMDz5Zh2RFqnwpgXd/IT9lbAVGlkWEE= github.com/aws/aws-sdk-go-v2 v1.39.0 h1:xm5WV/2L4emMRmMjHFykqiA4M/ra0DJVSWUkDyBjbg4= github.com/aws/aws-sdk-go-v2 v1.39.0/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= github.com/aws/aws-sdk-go-v2/config v1.31.8 h1:kQjtOLlTU4m4A64TsRcqwNChhGCwaPBt+zCQt/oWsHU= diff --git a/modules/letsencrypt/module.go b/modules/letsencrypt/module.go index 04085300..1a2004f8 100644 --- a/modules/letsencrypt/module.go +++ b/modules/letsencrypt/module.go @@ -147,7 +147,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/route53" "github.com/go-acme/lego/v4/registration" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/letsencrypt/module_test.go b/modules/letsencrypt/module_test.go index 9adaa950..63180cf6 100644 --- a/modules/letsencrypt/module_test.go +++ b/modules/letsencrypt/module_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular/modules/httpserver" + "github.com/GoCodeAlone/modular/modules/httpserver" "github.com/go-acme/lego/v4/certificate" ) diff --git a/modules/logmasker/README.md b/modules/logmasker/README.md index 5d76c0a1..af59d3cb 100644 --- a/modules/logmasker/README.md +++ b/modules/logmasker/README.md @@ -1,6 +1,6 @@ # LogMasker Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/logmasker.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/logmasker) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/logmasker.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/logmasker) The LogMasker Module provides centralized log masking functionality for Modular applications. It acts as a decorator around the standard Logger interface to automatically redact sensitive information from log output based on configurable rules. @@ -20,7 +20,7 @@ The LogMasker Module provides centralized log masking functionality for Modular Add the logmasker module to your project: ```bash -go get github.com/CrisisTextLine/modular/modules/logmasker +go get github.com/GoCodeAlone/modular/modules/logmasker ``` ## Configuration @@ -72,8 +72,8 @@ Register the module and use the masking logger service: package main import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/logmasker" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/logmasker" ) func main() { diff --git a/modules/logmasker/go.mod b/modules/logmasker/go.mod index 510c50ea..c32f3371 100644 --- a/modules/logmasker/go.mod +++ b/modules/logmasker/go.mod @@ -1,8 +1,8 @@ -module github.com/CrisisTextLine/modular/modules/logmasker +module github.com/GoCodeAlone/modular/modules/logmasker go 1.25 -require github.com/CrisisTextLine/modular v1.11.11 +require github.com/GoCodeAlone/modular v1.12.0 require ( github.com/BurntSushi/toml v1.6.0 // indirect diff --git a/modules/logmasker/go.sum b/modules/logmasker/go.sum index 927a069c..baabf235 100644 --- a/modules/logmasker/go.sum +++ b/modules/logmasker/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= +github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= diff --git a/modules/logmasker/module.go b/modules/logmasker/module.go index 9beb7375..4ad1084d 100644 --- a/modules/logmasker/module.go +++ b/modules/logmasker/module.go @@ -68,7 +68,7 @@ import ( "regexp" "strings" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // ErrInvalidConfigType indicates the configuration type is incorrect for this module. diff --git a/modules/logmasker/module_test.go b/modules/logmasker/module_test.go index 5d66c6f3..474baa10 100644 --- a/modules/logmasker/module_test.go +++ b/modules/logmasker/module_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // MockLogger implements modular.Logger for testing. diff --git a/modules/reverseproxy/DOCUMENTATION.md b/modules/reverseproxy/DOCUMENTATION.md index c666c5b1..845e86d0 100644 --- a/modules/reverseproxy/DOCUMENTATION.md +++ b/modules/reverseproxy/DOCUMENTATION.md @@ -70,7 +70,7 @@ ## Introduction -The Reverse Proxy module is a powerful and flexible API gateway component that routes HTTP requests to multiple backend services and provides advanced features for response aggregation, custom transformations, and tenant-aware routing. It's built for the [Modular](https://github.com/CrisisTextLine/modular) framework and designed to be easily configurable while supporting complex routing scenarios. +The Reverse Proxy module is a powerful and flexible API gateway component that routes HTTP requests to multiple backend services and provides advanced features for response aggregation, custom transformations, and tenant-aware routing. It's built for the [Modular](https://github.com/GoCodeAlone/modular) framework and designed to be easily configurable while supporting complex routing scenarios. ### Key Features @@ -125,7 +125,7 @@ The module works by: To use the Reverse Proxy module in your Go application: ```go -go get github.com/CrisisTextLine/modular/modules/reverseproxy@v1.0.0 +go get github.com/GoCodeAlone/modular/modules/reverseproxy@v1.0.0 ``` ## Configuration diff --git a/modules/reverseproxy/FEATURE_FLAG_MIGRATION_GUIDE.md b/modules/reverseproxy/FEATURE_FLAG_MIGRATION_GUIDE.md index 7b6053bd..3a19b4ee 100644 --- a/modules/reverseproxy/FEATURE_FLAG_MIGRATION_GUIDE.md +++ b/modules/reverseproxy/FEATURE_FLAG_MIGRATION_GUIDE.md @@ -91,7 +91,7 @@ func (e *MyCustomEvaluator) EvaluateFlag(ctx context.Context, flagID string, ten The new system supports special sentinel errors for better control: ```go -import "github.com/CrisisTextLine/modular/modules/reverseproxy" +import "github.com/GoCodeAlone/modular/modules/reverseproxy" func (e *MyCustomEvaluator) EvaluateFlag(ctx context.Context, flagID string, tenantID modular.TenantID, req *http.Request) (bool, error) { // Check if you can make a decision diff --git a/modules/reverseproxy/README.md b/modules/reverseproxy/README.md index af8858e6..f41e44dc 100644 --- a/modules/reverseproxy/README.md +++ b/modules/reverseproxy/README.md @@ -1,8 +1,8 @@ # Reverse Proxy Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/reverseproxy.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/reverseproxy) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/reverseproxy.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/reverseproxy) -A module for the [Modular](https://github.com/CrisisTextLine/modular) framework that provides a flexible reverse proxy with advanced routing capabilities. +A module for the [Modular](https://github.com/GoCodeAlone/modular) framework that provides a flexible reverse proxy with advanced routing capabilities. ## Overview @@ -24,6 +24,9 @@ The Reverse Proxy module functions as a versatile API gateway that can route req * **Tenant Awareness**: Support for multi-tenant environments with tenant-specific routing * **Pattern-Based Routing**: Direct requests to specific backends based on URL patterns * **Custom Endpoint Mapping**: Define flexible mappings from frontend endpoints to backend services +* **Pipeline Strategy**: Chain backend requests where each stage's response informs the next (map/reduce) +* **Fan-Out-Merge Strategy**: Parallel backend requests with custom ID-based response merging +* **Empty Response Policies**: Configurable handling of empty backend responses (allow, skip, or fail) * **Health Checking**: Continuous monitoring of backend service availability with DNS resolution and HTTP checks * **Circuit Breaker**: Automatic failure detection and recovery with configurable thresholds * **Response Caching**: Performance optimization with TTL-based caching @@ -33,7 +36,7 @@ The Reverse Proxy module functions as a versatile API gateway that can route req ## Installation ```go -go get github.com/CrisisTextLine/modular/modules/reverseproxy@v1.0.0 +go get github.com/GoCodeAlone/modular/modules/reverseproxy@v1.0.0 ``` ## Documentation @@ -48,9 +51,9 @@ go get github.com/CrisisTextLine/modular/modules/reverseproxy@v1.0.0 package main import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/chimux" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/chimux" + "github.com/GoCodeAlone/modular/modules/reverseproxy" "log/slog" "os" ) @@ -366,6 +369,139 @@ The module supports several advanced features: 11. **Connection Pooling**: Advanced connection pool management with configurable limits 12. **Queue Management**: Request queueing with configurable sizes and timeouts 13. **Error Handling**: Comprehensive error handling with custom pages and retry logic +14. **Pipeline Strategy**: Chain backend requests where each stage's response informs the next request (map/reduce pattern) +15. **Fan-Out-Merge Strategy**: Parallel backend requests with custom ID-based response merging +16. **Empty Response Policies**: Configurable handling of empty backend responses (allow, skip, or fail) + +### Composite Route Strategies + +Composite routes allow combining responses from multiple backend services. The module supports five strategies: + +#### first-success +Tries backends sequentially until one succeeds. Use case: High-availability setup with primary and fallback backends. + +```yaml +composite_routes: + "/api/data": + pattern: "/api/data" + backends: ["primary-backend", "fallback-backend"] + strategy: "first-success" +``` + +#### merge +Executes all backend requests in parallel and merges JSON responses by backend ID. + +```yaml +composite_routes: + "/api/user/profile": + pattern: "/api/user/profile" + backends: ["user-backend", "analytics-backend"] + strategy: "merge" +``` + +#### sequential +Executes requests one at a time, returning the last successful response. + +```yaml +composite_routes: + "/api/process": + pattern: "/api/process" + backends: ["auth-backend", "processing-backend"] + strategy: "sequential" +``` + +#### pipeline +Executes backends sequentially where each stage's response can inform the next stage's request. Requires programmatic configuration via `SetPipelineConfig()`. + +Use case: A list page shows queued conversations. Backend A returns conversation details, those IDs are fed into Backend B to fetch follow-up information, and the responses are merged. + +```yaml +composite_routes: + "/api/conversations": + pattern: "/api/conversations" + backends: ["conversations-backend", "followup-backend"] + strategy: "pipeline" + empty_policy: "skip-empty" # Optional: allow-empty, skip-empty, fail-on-empty +``` + +```go +proxyModule.SetPipelineConfig("/api/conversations", reverseproxy.PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, + previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + // Extract IDs from previous response and build next request + var convResp struct { + Conversations []struct{ ID string `json:"id"` } `json:"conversations"` + } + json.Unmarshal(previousResponses["conversations-backend"], &convResp) + + ids := []string{} + for _, c := range convResp.Conversations { + ids = append(ids, c.ID) + } + url := "http://followup-service/followups?ids=" + strings.Join(ids, ",") + return http.NewRequestWithContext(ctx, "GET", url, nil) + }, + ResponseMerger: func(ctx context.Context, originalReq *http.Request, + allResponses map[string][]byte) (*http.Response, error) { + // Merge follow-up data into conversations + // ... custom merging logic ... + return reverseproxy.MakeJSONResponse(http.StatusOK, mergedResult) + }, +}) +``` + +#### fan-out-merge +Executes all backends in parallel (like merge), then applies a custom merger function for ID-based matching, filtering, or complex data correlation. Requires programmatic configuration via `SetFanOutMerger()`. + +Use case: A ticket dashboard where tickets come from one service and priority/assignment data comes from another. The merger matches by ticket ID. + +```yaml +composite_routes: + "/api/tickets": + pattern: "/api/tickets" + backends: ["tickets-backend", "assignments-backend"] + strategy: "fan-out-merge" + empty_policy: "allow-empty" # Optional +``` + +```go +proxyModule.SetFanOutMerger("/api/tickets", func(ctx context.Context, + originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + // Parse both responses + var ticketsResp struct { Tickets []map[string]interface{} `json:"tickets"` } + json.Unmarshal(responses["tickets-backend"], &ticketsResp) + + var assignResp struct { Assignments map[string]interface{} `json:"assignments"` } + json.Unmarshal(responses["assignments-backend"], &assignResp) + + // Merge by ID + for i, ticket := range ticketsResp.Tickets { + if id, ok := ticket["id"].(string); ok { + if assignment, exists := assignResp.Assignments[id]; exists { + ticketsResp.Tickets[i]["assignment"] = assignment + } + } + } + return reverseproxy.MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "tickets": ticketsResp.Tickets, + }) +}) +``` + +#### Empty Response Policies + +For `pipeline` and `fan-out-merge` strategies, you can control how empty backend responses are handled: + +| Policy | Description | +|--------|-------------| +| `allow-empty` | Include empty responses in the result set (default) | +| `skip-empty` | Silently drop empty responses from the result | +| `fail-on-empty` | Fail the entire request if any backend returns empty | + +Set via config (`empty_policy` field) or programmatically: +```go +proxyModule.SetEmptyResponsePolicy("/api/route", reverseproxy.EmptyResponseSkip) +``` ### Debug Endpoints diff --git a/modules/reverseproxy/backend_test.go b/modules/reverseproxy/backend_test.go index 162ed00f..1f05c8d4 100644 --- a/modules/reverseproxy/backend_test.go +++ b/modules/reverseproxy/backend_test.go @@ -9,7 +9,7 @@ import ( "net/url" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/bdd_caching_tenant_test.go b/modules/reverseproxy/bdd_caching_tenant_test.go index 5542924d..760891ab 100644 --- a/modules/reverseproxy/bdd_caching_tenant_test.go +++ b/modules/reverseproxy/bdd_caching_tenant_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Caching Scenarios diff --git a/modules/reverseproxy/bdd_circuit_error_scenarios_test.go b/modules/reverseproxy/bdd_circuit_error_scenarios_test.go index bf9a1be7..743aaf14 100644 --- a/modules/reverseproxy/bdd_circuit_error_scenarios_test.go +++ b/modules/reverseproxy/bdd_circuit_error_scenarios_test.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Circuit Breaker Response Scenarios diff --git a/modules/reverseproxy/bdd_composite_pipeline_test.go b/modules/reverseproxy/bdd_composite_pipeline_test.go new file mode 100644 index 00000000..0334cd6f --- /dev/null +++ b/modules/reverseproxy/bdd_composite_pipeline_test.go @@ -0,0 +1,720 @@ +package reverseproxy + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "time" +) + +// ============================================================================ +// Pipeline Strategy BDD Steps +// ============================================================================ + +func (ctx *ReverseProxyBDDTestContext) iHaveAPipelineCompositeRouteWithTwoBackends() error { + ctx.resetContext() + + // Backend 1: returns a list of items with IDs + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "items": []map[string]interface{}{ + {"id": "item-1", "name": "First Item"}, + {"id": "item-2", "name": "Second Item"}, + }, + }) + })) + ctx.testServers = append(ctx.testServers, backend1) + + // Backend 2: returns details for given IDs + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + idsParam := r.URL.Query().Get("ids") + details := make(map[string]interface{}) + if idsParam != "" { + for _, id := range strings.Split(idsParam, ",") { + if id == "item-1" { + details[id] = map[string]interface{}{"category": "A", "priority": "high"} + } + if id == "item-2" { + details[id] = map[string]interface{}{"category": "B", "priority": "low"} + } + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "details": details, + }) + })) + ctx.testServers = append(ctx.testServers, backend2) + + ctx.config = &ReverseProxyConfig{ + DefaultBackend: "items-backend", + BackendServices: map[string]string{ + "items-backend": backend1.URL, + "details-backend": backend2.URL, + }, + CompositeRoutes: map[string]CompositeRoute{ + "/api/pipeline": { + Pattern: "/api/pipeline", + Backends: []string{"items-backend", "details-backend"}, + Strategy: "pipeline", + }, + }, + HealthCheck: HealthCheckConfig{ + Enabled: false, + Interval: 30 * time.Second, + }, + CircuitBreakerConfig: CircuitBreakerConfig{ + Enabled: false, + }, + } + + // Capture backend2 URL for use in the closure + backend2URL := backend2.URL + + if err := ctx.setupApplicationWithConfig(); err != nil { + return err + } + + // Set pipeline config on the module AFTER setup creates it + ctx.module.SetPipelineConfig("/api/pipeline", PipelineConfig{ + RequestBuilder: func(rctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + if nextBackendID == "details-backend" { + var itemsResp struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + if body, ok := previousResponses["items-backend"]; ok { + if err := json.Unmarshal(body, &itemsResp); err != nil { + return nil, err + } + } + ids := make([]string, 0, len(itemsResp.Items)) + for _, item := range itemsResp.Items { + ids = append(ids, item.ID) + } + url := backend2URL + "/details?ids=" + strings.Join(ids, ",") + return http.NewRequestWithContext(rctx, "GET", url, nil) + } + return nil, fmt.Errorf("unknown backend: %s", nextBackendID) + }, + ResponseMerger: func(rctx context.Context, originalReq *http.Request, allResponses map[string][]byte) (*http.Response, error) { + var itemsResp struct { + Items []map[string]interface{} `json:"items"` + } + json.Unmarshal(allResponses["items-backend"], &itemsResp) + + var detailsResp struct { + Details map[string]interface{} `json:"details"` + } + json.Unmarshal(allResponses["details-backend"], &detailsResp) + + for i, item := range itemsResp.Items { + if id, ok := item["id"].(string); ok { + if detail, exists := detailsResp.Details[id]; exists { + itemsResp.Items[i]["detail"] = detail + } + } + } + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "items": itemsResp.Items, + }) + }, + }) + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) iSendARequestToThePipelineRoute() error { + if ctx.module == nil { + return fmt.Errorf("module not initialized") + } + + backends := []*Backend{ + {ID: "items-backend", URL: ctx.module.config.BackendServices["items-backend"], Client: http.DefaultClient}, + {ID: "details-backend", URL: ctx.module.config.BackendServices["details-backend"], Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + + if pipelineCfg, ok := ctx.module.pipelineConfigs["/api/pipeline"]; ok { + handler.SetPipelineConfig(pipelineCfg) + } + + req := httptest.NewRequest("GET", "/api/pipeline", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + ctx.lastResponse = resp + body, _ := io.ReadAll(resp.Body) + ctx.lastResponseBody = body + resp.Body = io.NopCloser(strings.NewReader(string(body))) + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) theFirstBackendShouldBeCalledWithTheOriginalRequest() error { + if ctx.lastResponse == nil { + return fmt.Errorf("no response received") + } + if ctx.lastResponse.StatusCode != http.StatusOK { + return fmt.Errorf("expected status 200, got %d", ctx.lastResponse.StatusCode) + } + return nil +} + +func (ctx *ReverseProxyBDDTestContext) theSecondBackendShouldReceiveDataDerivedFromTheFirstResponse() error { + if ctx.lastResponseBody == nil { + return fmt.Errorf("no response body") + } + var result map[string]interface{} + if err := json.Unmarshal(ctx.lastResponseBody, &result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + items, ok := result["items"].([]interface{}) + if !ok { + return fmt.Errorf("expected items array in response") + } + if len(items) == 0 { + return fmt.Errorf("expected at least one item") + } + // Check that items have detail data (proving second backend was called with IDs from first) + item1 := items[0].(map[string]interface{}) + if _, hasDetail := item1["detail"]; !hasDetail { + return fmt.Errorf("item should have detail from second backend") + } + return nil +} + +func (ctx *ReverseProxyBDDTestContext) theFinalResponseShouldContainMergedDataFromAllStages() error { + var result map[string]interface{} + if err := json.Unmarshal(ctx.lastResponseBody, &result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + items := result["items"].([]interface{}) + if len(items) != 2 { + return fmt.Errorf("expected 2 items, got %d", len(items)) + } + + // Verify item-1 has detail with category A + item1 := items[0].(map[string]interface{}) + detail1 := item1["detail"].(map[string]interface{}) + if detail1["category"] != "A" { + return fmt.Errorf("item-1 should have category A") + } + return nil +} + +// ============================================================================ +// Fan-Out-Merge Strategy BDD Steps +// ============================================================================ + +func (ctx *ReverseProxyBDDTestContext) iHaveAFanOutMergeCompositeRouteWithTwoBackends() error { + ctx.resetContext() + + // Backend A: returns items + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "records": []map[string]interface{}{ + {"id": "r1", "title": "Record One"}, + {"id": "r2", "title": "Record Two"}, + {"id": "r3", "title": "Record Three"}, + }, + }) + })) + ctx.testServers = append(ctx.testServers, backendA) + + // Backend B: returns tags keyed by ID + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "tags": map[string]interface{}{ + "r1": []string{"urgent", "new"}, + "r3": []string{"follow-up"}, + }, + }) + })) + ctx.testServers = append(ctx.testServers, backendB) + + ctx.config = &ReverseProxyConfig{ + DefaultBackend: "records-backend", + BackendServices: map[string]string{ + "records-backend": backendA.URL, + "tags-backend": backendB.URL, + }, + CompositeRoutes: map[string]CompositeRoute{ + "/api/fanout": { + Pattern: "/api/fanout", + Backends: []string{"records-backend", "tags-backend"}, + Strategy: "fan-out-merge", + }, + }, + HealthCheck: HealthCheckConfig{ + Enabled: false, + Interval: 30 * time.Second, + }, + CircuitBreakerConfig: CircuitBreakerConfig{ + Enabled: false, + }, + } + + if err := ctx.setupApplicationWithConfig(); err != nil { + return err + } + + // Set fan-out merger AFTER setup creates the module + ctx.module.SetFanOutMerger("/api/fanout", func(rctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + var recordsResp struct { + Records []map[string]interface{} `json:"records"` + } + json.Unmarshal(responses["records-backend"], &recordsResp) + + var tagsResp struct { + Tags map[string]interface{} `json:"tags"` + } + json.Unmarshal(responses["tags-backend"], &tagsResp) + + for i, record := range recordsResp.Records { + if id, ok := record["id"].(string); ok { + if tags, exists := tagsResp.Tags[id]; exists { + recordsResp.Records[i]["tags"] = tags + } + } + } + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "records": recordsResp.Records, + }) + }) + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) iSendARequestToTheFanOutMergeRoute() error { + if ctx.module == nil { + return fmt.Errorf("module not initialized") + } + + backends := []*Backend{ + {ID: "records-backend", URL: ctx.module.config.BackendServices["records-backend"], Client: http.DefaultClient}, + {ID: "tags-backend", URL: ctx.module.config.BackendServices["tags-backend"], Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + + if merger, ok := ctx.module.fanOutMergers["/api/fanout"]; ok { + handler.SetFanOutMerger(merger) + } + + req := httptest.NewRequest("GET", "/api/fanout", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + ctx.lastResponse = resp + body, _ := io.ReadAll(resp.Body) + ctx.lastResponseBody = body + resp.Body = io.NopCloser(strings.NewReader(string(body))) + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) bothBackendsShouldBeCalledInParallel() error { + if ctx.lastResponse == nil { + return fmt.Errorf("no response received") + } + if ctx.lastResponse.StatusCode != http.StatusOK { + return fmt.Errorf("expected status 200, got %d", ctx.lastResponse.StatusCode) + } + return nil +} + +func (ctx *ReverseProxyBDDTestContext) theResponsesShouldBeMergedByMatchingIDs() error { + var result map[string]interface{} + if err := json.Unmarshal(ctx.lastResponseBody, &result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + records, ok := result["records"].([]interface{}) + if !ok { + return fmt.Errorf("expected records array") + } + if len(records) != 3 { + return fmt.Errorf("expected 3 records, got %d", len(records)) + } + return nil +} + +func (ctx *ReverseProxyBDDTestContext) itemsWithMatchingAncillaryDataShouldBeEnriched() error { + var result map[string]interface{} + json.Unmarshal(ctx.lastResponseBody, &result) + + records := result["records"].([]interface{}) + + // r1 should have tags + r1 := records[0].(map[string]interface{}) + if _, hasTags := r1["tags"]; !hasTags { + return fmt.Errorf("r1 should have tags") + } + + // r2 should NOT have tags + r2 := records[1].(map[string]interface{}) + if _, hasTags := r2["tags"]; hasTags { + return fmt.Errorf("r2 should NOT have tags") + } + + // r3 should have tags + r3 := records[2].(map[string]interface{}) + if _, hasTags := r3["tags"]; !hasTags { + return fmt.Errorf("r3 should have tags") + } + + return nil +} + +// ============================================================================ +// Empty Response Policy BDD Steps +// ============================================================================ + +func (ctx *ReverseProxyBDDTestContext) iHaveAPipelineRouteWithSkipEmptyPolicy() error { + ctx.resetContext() + + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"from-stage1","value":42}`)) + })) + ctx.testServers = append(ctx.testServers, backend1) + + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) // Empty response + })) + ctx.testServers = append(ctx.testServers, backend2) + + // Capture URL for closure + backend2URL := backend2.URL + + ctx.config = &ReverseProxyConfig{ + DefaultBackend: "data-backend", + BackendServices: map[string]string{ + "data-backend": backend1.URL, + "empty-backend": backend2.URL, + }, + CompositeRoutes: map[string]CompositeRoute{ + "/api/skip-empty": { + Pattern: "/api/skip-empty", + Backends: []string{"data-backend", "empty-backend"}, + Strategy: "pipeline", + EmptyPolicy: "skip-empty", + }, + }, + HealthCheck: HealthCheckConfig{Enabled: false, Interval: 30 * time.Second}, + CircuitBreakerConfig: CircuitBreakerConfig{Enabled: false}, + } + + if err := ctx.setupApplicationWithConfig(); err != nil { + return err + } + + // Set pipeline config and empty policy AFTER setup + ctx.module.SetPipelineConfig("/api/skip-empty", PipelineConfig{ + RequestBuilder: func(rctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return http.NewRequestWithContext(rctx, "GET", backend2URL+"/test", nil) + }, + }) + ctx.module.SetEmptyResponsePolicy("/api/skip-empty", EmptyResponseSkip) + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) iSendARequestAndABackendReturnsAnEmptyResponse() error { + if ctx.module == nil { + return fmt.Errorf("module not initialized") + } + + var strategy CompositeStrategy + var pattern string + for p, route := range ctx.module.config.CompositeRoutes { + strategy = CompositeStrategy(route.Strategy) + pattern = p + break + } + + var backends []*Backend + for _, name := range ctx.module.config.CompositeRoutes[pattern].Backends { + backends = append(backends, &Backend{ + ID: name, + URL: ctx.module.config.BackendServices[name], + Client: http.DefaultClient, + }) + } + + handler := NewCompositeHandler(backends, strategy, 10*time.Second) + + emptyPolicy := EmptyResponsePolicy(ctx.module.config.CompositeRoutes[pattern].EmptyPolicy) + if policy, ok := ctx.module.emptyResponsePolicies[pattern]; ok { + emptyPolicy = policy + } + handler.SetEmptyResponsePolicy(emptyPolicy) + + if pipelineCfg, ok := ctx.module.pipelineConfigs[pattern]; ok { + handler.SetPipelineConfig(pipelineCfg) + } + if merger, ok := ctx.module.fanOutMergers[pattern]; ok { + handler.SetFanOutMerger(merger) + } + + req := httptest.NewRequest("GET", pattern, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + ctx.lastResponse = resp + body, _ := io.ReadAll(resp.Body) + ctx.lastResponseBody = body + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) theEmptyResponseShouldBeExcludedFromTheResult() error { + var result map[string]interface{} + if err := json.Unmarshal(ctx.lastResponseBody, &result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + // "empty-backend" should not be in the response + if _, found := result["empty-backend"]; found { + return fmt.Errorf("empty backend response should be excluded") + } + return nil +} + +func (ctx *ReverseProxyBDDTestContext) theNonEmptyResponsesShouldStillBePresent() error { + var result map[string]interface{} + json.Unmarshal(ctx.lastResponseBody, &result) + + if _, found := result["data-backend"]; !found { + return fmt.Errorf("data-backend response should be present") + } + return nil +} + +func (ctx *ReverseProxyBDDTestContext) iHaveAFanOutMergeRouteWithFailOnEmptyPolicy() error { + ctx.resetContext() + + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"ok"}`)) + })) + ctx.testServers = append(ctx.testServers, backendA) + + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(``)) // Completely empty + })) + ctx.testServers = append(ctx.testServers, backendB) + + ctx.config = &ReverseProxyConfig{ + DefaultBackend: "ok-backend", + BackendServices: map[string]string{ + "ok-backend": backendA.URL, + "empty-backend": backendB.URL, + }, + CompositeRoutes: map[string]CompositeRoute{ + "/api/fail-empty": { + Pattern: "/api/fail-empty", + Backends: []string{"ok-backend", "empty-backend"}, + Strategy: "fan-out-merge", + EmptyPolicy: "fail-on-empty", + }, + }, + HealthCheck: HealthCheckConfig{Enabled: false, Interval: 30 * time.Second}, + CircuitBreakerConfig: CircuitBreakerConfig{Enabled: false}, + } + + if err := ctx.setupApplicationWithConfig(); err != nil { + return err + } + + // Set merger and policy AFTER setup + ctx.module.SetFanOutMerger("/api/fail-empty", func(rctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + return MakeJSONResponse(http.StatusOK, responses) + }) + ctx.module.SetEmptyResponsePolicy("/api/fail-empty", EmptyResponseFail) + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) theRequestShouldFailWithABadGatewayError() error { + if ctx.lastResponse == nil { + return fmt.Errorf("no response received") + } + if ctx.lastResponse.StatusCode != http.StatusBadGateway { + return fmt.Errorf("expected status 502, got %d", ctx.lastResponse.StatusCode) + } + return nil +} + +// ============================================================================ +// Pipeline Filter BDD Steps +// ============================================================================ + +func (ctx *ReverseProxyBDDTestContext) iHaveAPipelineRouteThatFiltersByAncillaryBackendData() error { + ctx.resetContext() + + // Backend A: returns all conversations + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "conversations": []map[string]interface{}{ + {"id": "c1", "title": "Conv 1", "status": "queued"}, + {"id": "c2", "title": "Conv 2", "status": "queued"}, + {"id": "c3", "title": "Conv 3", "status": "active"}, + {"id": "c4", "title": "Conv 4", "status": "queued"}, + }, + }) + })) + ctx.testServers = append(ctx.testServers, backendA) + + // Backend B: returns which conversations are flagged + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "flagged_ids": []string{"c1", "c4"}, + }) + })) + ctx.testServers = append(ctx.testServers, backendB) + + // Capture URL for closure + backendBURL := backendB.URL + + ctx.config = &ReverseProxyConfig{ + DefaultBackend: "conv-backend", + BackendServices: map[string]string{ + "conv-backend": backendA.URL, + "flags-backend": backendB.URL, + }, + CompositeRoutes: map[string]CompositeRoute{ + "/api/filter": { + Pattern: "/api/filter", + Backends: []string{"conv-backend", "flags-backend"}, + Strategy: "pipeline", + }, + }, + HealthCheck: HealthCheckConfig{Enabled: false, Interval: 30 * time.Second}, + CircuitBreakerConfig: CircuitBreakerConfig{Enabled: false}, + } + + if err := ctx.setupApplicationWithConfig(); err != nil { + return err + } + + // Set pipeline config AFTER setup + ctx.module.SetPipelineConfig("/api/filter", PipelineConfig{ + RequestBuilder: func(rctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return http.NewRequestWithContext(rctx, "GET", backendBURL+"/flags", nil) + }, + ResponseMerger: func(rctx context.Context, originalReq *http.Request, allResponses map[string][]byte) (*http.Response, error) { + var convResp struct { + Conversations []map[string]interface{} `json:"conversations"` + } + json.Unmarshal(allResponses["conv-backend"], &convResp) + + var flagsResp struct { + FlaggedIDs []string `json:"flagged_ids"` + } + json.Unmarshal(allResponses["flags-backend"], &flagsResp) + + flagSet := make(map[string]bool) + for _, id := range flagsResp.FlaggedIDs { + flagSet[id] = true + } + + var filtered []map[string]interface{} + for _, conv := range convResp.Conversations { + if id, ok := conv["id"].(string); ok && flagSet[id] { + conv["flagged"] = true + filtered = append(filtered, conv) + } + } + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "filtered_conversations": filtered, + "total_filtered": len(filtered), + }) + }, + }) + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) iSendARequestToFetchFilteredResults() error { + if ctx.module == nil { + return fmt.Errorf("module not initialized") + } + + backends := []*Backend{ + {ID: "conv-backend", URL: ctx.module.config.BackendServices["conv-backend"], Client: http.DefaultClient}, + {ID: "flags-backend", URL: ctx.module.config.BackendServices["flags-backend"], Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + if pipelineCfg, ok := ctx.module.pipelineConfigs["/api/filter"]; ok { + handler.SetPipelineConfig(pipelineCfg) + } + + req := httptest.NewRequest("GET", "/api/filter", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + ctx.lastResponse = resp + body, _ := io.ReadAll(resp.Body) + ctx.lastResponseBody = body + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) onlyItemsMatchingTheAncillaryCriteriaShouldBeReturned() error { + var result map[string]interface{} + if err := json.Unmarshal(ctx.lastResponseBody, &result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + filtered := result["filtered_conversations"].([]interface{}) + totalFiltered := result["total_filtered"].(float64) + + if int(totalFiltered) != 2 { + return fmt.Errorf("expected 2 filtered conversations, got %v", totalFiltered) + } + if len(filtered) != 2 { + return fmt.Errorf("expected 2 filtered conversations in array, got %d", len(filtered)) + } + + // Verify only c1 and c4 are present + ids := make(map[string]bool) + for _, item := range filtered { + m := item.(map[string]interface{}) + ids[m["id"].(string)] = true + if m["flagged"] != true { + return fmt.Errorf("expected flagged=true on filtered item") + } + } + + if !ids["c1"] || !ids["c4"] { + return fmt.Errorf("expected c1 and c4 in filtered results, got %v", ids) + } + + return nil +} diff --git a/modules/reverseproxy/bdd_core_module_test.go b/modules/reverseproxy/bdd_core_module_test.go index e3ca6f01..43f640d9 100644 --- a/modules/reverseproxy/bdd_core_module_test.go +++ b/modules/reverseproxy/bdd_core_module_test.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/reverseproxy/bdd_debug_auth_test.go b/modules/reverseproxy/bdd_debug_auth_test.go index 24864cfe..0312ab92 100644 --- a/modules/reverseproxy/bdd_debug_auth_test.go +++ b/modules/reverseproxy/bdd_debug_auth_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestDebugAuthScenarios tests the authenticated debug endpoints BDD scenarios diff --git a/modules/reverseproxy/bdd_events_test.go b/modules/reverseproxy/bdd_events_test.go index 03e57e94..327fdf4d 100644 --- a/modules/reverseproxy/bdd_events_test.go +++ b/modules/reverseproxy/bdd_events_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Event Observation Scenarios diff --git a/modules/reverseproxy/bdd_feature_flag_dryrun_test.go b/modules/reverseproxy/bdd_feature_flag_dryrun_test.go index 8891ee57..2a3bc1ed 100644 --- a/modules/reverseproxy/bdd_feature_flag_dryrun_test.go +++ b/modules/reverseproxy/bdd_feature_flag_dryrun_test.go @@ -8,8 +8,8 @@ import ( "strings" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" ) // BDD Test: Feature-flagged composite route with dry-run fallback diff --git a/modules/reverseproxy/bdd_feature_flag_scenarios_test.go b/modules/reverseproxy/bdd_feature_flag_scenarios_test.go index 524afc13..d9372363 100644 --- a/modules/reverseproxy/bdd_feature_flag_scenarios_test.go +++ b/modules/reverseproxy/bdd_feature_flag_scenarios_test.go @@ -7,7 +7,7 @@ import ( "net/http" "net/http/httptest" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Feature Flag Scenario Step Implementations diff --git a/modules/reverseproxy/bdd_feature_flag_steps_test.go b/modules/reverseproxy/bdd_feature_flag_steps_test.go index 4beb0457..0a3af85b 100644 --- a/modules/reverseproxy/bdd_feature_flag_steps_test.go +++ b/modules/reverseproxy/bdd_feature_flag_steps_test.go @@ -4,7 +4,7 @@ import ( "fmt" "sort" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // mockFeatureFlagEvaluator implements FeatureFlagEvaluator for testing diff --git a/modules/reverseproxy/bdd_feature_flags_test.go b/modules/reverseproxy/bdd_feature_flags_test.go index e1cd0fe4..d3c35b20 100644 --- a/modules/reverseproxy/bdd_feature_flags_test.go +++ b/modules/reverseproxy/bdd_feature_flags_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Feature Flag Scenarios diff --git a/modules/reverseproxy/bdd_health_events_test.go b/modules/reverseproxy/bdd_health_events_test.go index 1a02ab35..c57cb2da 100644 --- a/modules/reverseproxy/bdd_health_events_test.go +++ b/modules/reverseproxy/bdd_health_events_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Missing implementation for setting up backends with health checking enabled diff --git a/modules/reverseproxy/bdd_metrics_debug_test.go b/modules/reverseproxy/bdd_metrics_debug_test.go index 67b8bedd..eef01b2e 100644 --- a/modules/reverseproxy/bdd_metrics_debug_test.go +++ b/modules/reverseproxy/bdd_metrics_debug_test.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Metrics Scenarios diff --git a/modules/reverseproxy/bdd_roundrobin_circuit_test.go b/modules/reverseproxy/bdd_roundrobin_circuit_test.go index 73db0fb7..5591798d 100644 --- a/modules/reverseproxy/bdd_roundrobin_circuit_test.go +++ b/modules/reverseproxy/bdd_roundrobin_circuit_test.go @@ -8,7 +8,7 @@ import ( "sync/atomic" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Round-robin with Circuit Breaker BDD Scenarios diff --git a/modules/reverseproxy/bdd_routing_loadbalancing_test.go b/modules/reverseproxy/bdd_routing_loadbalancing_test.go index e930de72..54a44e89 100644 --- a/modules/reverseproxy/bdd_routing_loadbalancing_test.go +++ b/modules/reverseproxy/bdd_routing_loadbalancing_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Single Backend Scenarios diff --git a/modules/reverseproxy/bdd_step_registry_test.go b/modules/reverseproxy/bdd_step_registry_test.go index d6915677..b509bd8b 100644 --- a/modules/reverseproxy/bdd_step_registry_test.go +++ b/modules/reverseproxy/bdd_step_registry_test.go @@ -380,6 +380,31 @@ func registerAllStepDefinitions(s *godog.ScenarioContext, ctx *ReverseProxyBDDTe // Timeout-related scenario steps (removing duplicate to avoid ambiguity) s.Then(`^appropriate timeout error responses should be returned$`, ctx.appropriateTimeoutErrorResponsesShouldBeReturned) + // Pipeline and Fan-Out-Merge Composite Strategy Steps (from bdd_composite_pipeline_test.go) + s.Given(`^I have a pipeline composite route with two backends$`, ctx.iHaveAPipelineCompositeRouteWithTwoBackends) + s.When(`^I send a request to the pipeline route$`, ctx.iSendARequestToThePipelineRoute) + s.Then(`^the first backend should be called with the original request$`, ctx.theFirstBackendShouldBeCalledWithTheOriginalRequest) + s.Then(`^the second backend should receive data derived from the first response$`, ctx.theSecondBackendShouldReceiveDataDerivedFromTheFirstResponse) + s.Then(`^the final response should contain merged data from all stages$`, ctx.theFinalResponseShouldContainMergedDataFromAllStages) + + s.Given(`^I have a fan-out-merge composite route with two backends$`, ctx.iHaveAFanOutMergeCompositeRouteWithTwoBackends) + s.When(`^I send a request to the fan-out-merge route$`, ctx.iSendARequestToTheFanOutMergeRoute) + s.Then(`^both backends should be called in parallel$`, ctx.bothBackendsShouldBeCalledInParallel) + s.Then(`^the responses should be merged by matching IDs$`, ctx.theResponsesShouldBeMergedByMatchingIDs) + s.Then(`^items with matching ancillary data should be enriched$`, ctx.itemsWithMatchingAncillaryDataShouldBeEnriched) + + s.Given(`^I have a pipeline route with skip-empty policy$`, ctx.iHaveAPipelineRouteWithSkipEmptyPolicy) + s.When(`^I send a request and a backend returns an empty response$`, ctx.iSendARequestAndABackendReturnsAnEmptyResponse) + s.Then(`^the empty response should be excluded from the result$`, ctx.theEmptyResponseShouldBeExcludedFromTheResult) + s.Then(`^the non-empty responses should still be present$`, ctx.theNonEmptyResponsesShouldStillBePresent) + + s.Given(`^I have a fan-out-merge route with fail-on-empty policy$`, ctx.iHaveAFanOutMergeRouteWithFailOnEmptyPolicy) + s.Then(`^the request should fail with a bad gateway error$`, ctx.theRequestShouldFailWithABadGatewayError) + + s.Given(`^I have a pipeline route that filters by ancillary backend data$`, ctx.iHaveAPipelineRouteThatFiltersByAncillaryBackendData) + s.When(`^I send a request to fetch filtered results$`, ctx.iSendARequestToFetchFilteredResults) + s.Then(`^only items matching the ancillary criteria should be returned$`, ctx.onlyItemsMatchingTheAncillaryCriteriaShouldBeReturned) + // Note: Most comprehensive step implementations are already in existing BDD files // Only add new steps here for scenarios that are completely missing implementations } diff --git a/modules/reverseproxy/bdd_tenant_caching_override_test.go b/modules/reverseproxy/bdd_tenant_caching_override_test.go index ae4ec0a1..6095f33b 100644 --- a/modules/reverseproxy/bdd_tenant_caching_override_test.go +++ b/modules/reverseproxy/bdd_tenant_caching_override_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/cucumber/godog" ) diff --git a/modules/reverseproxy/bdd_tenant_header_enforcement_test.go b/modules/reverseproxy/bdd_tenant_header_enforcement_test.go index 88683deb..d439d059 100644 --- a/modules/reverseproxy/bdd_tenant_header_enforcement_test.go +++ b/modules/reverseproxy/bdd_tenant_header_enforcement_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestTenantHeaderEnforcementBDD runs BDD scenarios for tenant header enforcement diff --git a/modules/reverseproxy/composite.go b/modules/reverseproxy/composite.go index 2aa91982..651bf87f 100644 --- a/modules/reverseproxy/composite.go +++ b/modules/reverseproxy/composite.go @@ -55,6 +55,15 @@ type CompositeHandler struct { responseCache *responseCache eventEmitter func(eventType string, data map[string]interface{}) responseTransformer ResponseTransformer + + // Pipeline strategy configuration + pipelineConfig *PipelineConfig + + // Fan-out-merge strategy merger function + fanOutMerger FanOutMerger + + // Empty response policy for pipeline and fan-out-merge strategies + emptyResponsePolicy EmptyResponsePolicy } // NewCompositeHandler creates a new composite handler with the given backends and strategy. @@ -121,6 +130,21 @@ func (h *CompositeHandler) SetResponseTransformer(transformer ResponseTransforme h.responseTransformer = transformer } +// SetPipelineConfig sets the pipeline configuration for pipeline strategy routes. +func (h *CompositeHandler) SetPipelineConfig(config *PipelineConfig) { + h.pipelineConfig = config +} + +// SetFanOutMerger sets the fan-out merger function for fan-out-merge strategy routes. +func (h *CompositeHandler) SetFanOutMerger(merger FanOutMerger) { + h.fanOutMerger = merger +} + +// SetEmptyResponsePolicy sets the empty response policy for pipeline and fan-out-merge strategies. +func (h *CompositeHandler) SetEmptyResponsePolicy(policy EmptyResponsePolicy) { + h.emptyResponsePolicy = policy +} + // ServeHTTP handles the request by forwarding it to all backends // and merging the responses. func (h *CompositeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -173,6 +197,10 @@ func (h *CompositeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.executeMerge(ctx, recorder, r, bodyBytes) case StrategySequential: h.executeSequential(ctx, recorder, r, bodyBytes) + case StrategyPipeline: + h.executePipeline(ctx, recorder, r, bodyBytes) + case StrategyFanOutMerge: + h.executeFanOutMerge(ctx, recorder, r, bodyBytes) default: // Default to first-success for unknown strategies h.executeFirstSuccess(ctx, recorder, r, bodyBytes) @@ -377,7 +405,7 @@ func (h *CompositeHandler) executeBackendRequest(ctx context.Context, backend *B } // Create a new request with the same method, URL, and headers. - req, err := http.NewRequestWithContext(ctx, r.Method, backendURL, nil) //nolint:gosec // G704: backendURL is built from configured backend.URL, not user input + req, err := http.NewRequestWithContext(ctx, r.Method, backendURL, nil) //nolint:gosec // G704: reverse proxy intentionally forwards requests to configured backends if err != nil { return nil, fmt.Errorf("failed to create new request: %w", err) } @@ -504,6 +532,17 @@ func (m *ReverseProxyModule) createCompositeHandler(ctx context.Context, routeCo // Create and configure the handler handler := NewCompositeHandler(backends, strategy, responseTimeout) + // Set empty response policy from config if specified + if routeConfig.EmptyPolicy != "" { + switch routeConfig.EmptyPolicy { + case string(EmptyResponseAllow), string(EmptyResponseSkip), string(EmptyResponseFail): + handler.SetEmptyResponsePolicy(EmptyResponsePolicy(routeConfig.EmptyPolicy)) + default: + return nil, fmt.Errorf("route %q empty_policy %q: %w", + routeConfig.Pattern, routeConfig.EmptyPolicy, ErrInvalidEmptyResponsePolicy) + } + } + // Set event emitter for circuit breaker events handler.SetEventEmitter(func(eventType string, data map[string]interface{}) { m.emitEvent(ctx, eventType, data) @@ -536,6 +575,21 @@ func (m *ReverseProxyModule) createCompositeHandler(ctx context.Context, routeCo handler.SetResponseTransformer(transformer) } + // Set pipeline config if available for this route + if pipelineCfg, exists := m.pipelineConfigs[routeConfig.Pattern]; exists { + handler.SetPipelineConfig(pipelineCfg) + } + + // Set fan-out merger if available for this route + if merger, exists := m.fanOutMergers[routeConfig.Pattern]; exists { + handler.SetFanOutMerger(merger) + } + + // Set empty response policy if available for this route + if policy, exists := m.emptyResponsePolicies[routeConfig.Pattern]; exists { + handler.SetEmptyResponsePolicy(policy) + } + return handler, nil } diff --git a/modules/reverseproxy/composite_pipeline.go b/modules/reverseproxy/composite_pipeline.go new file mode 100644 index 00000000..bf38f35f --- /dev/null +++ b/modules/reverseproxy/composite_pipeline.go @@ -0,0 +1,417 @@ +// Package reverseproxy provides a flexible reverse proxy module with support for multiple backends, +// composite responses, and tenant awareness. +package reverseproxy + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" +) + +const ( + // StrategyPipeline executes backends sequentially where each stage's response + // can inform the next stage's request. This enables map/reduce patterns where + // backend B's request is constructed from backend A's response. + // + // Example: Backend A returns a list of conversation IDs, backend B is called + // with those IDs to fetch ancillary details, and the responses are merged. + // + // Requires a PipelineConfig to be set via SetPipelineConfig. + StrategyPipeline CompositeStrategy = "pipeline" + + // StrategyFanOutMerge executes all backend requests in parallel (like merge), + // then applies a custom FanOutMerger function to perform ID-based matching, + // filtering, and complex merging logic across all responses. + // + // Example: Backend A returns conversations, backend B returns follow-up flags. + // The merger matches by conversation ID and produces a unified response. + // + // Requires a FanOutMerger to be set via SetFanOutMerger. + StrategyFanOutMerge CompositeStrategy = "fan-out-merge" +) + +// EmptyResponsePolicy defines how empty backend responses should be handled +// in pipeline and fan-out-merge strategies. +type EmptyResponsePolicy string + +const ( + // EmptyResponseAllow includes empty responses in the result set. + // Backends that return no data are represented as empty/nil in the response map. + EmptyResponseAllow EmptyResponsePolicy = "allow-empty" + + // EmptyResponseSkip silently drops empty responses from the result set. + // The merger/pipeline receives only non-empty responses. + EmptyResponseSkip EmptyResponsePolicy = "skip-empty" + + // EmptyResponseFail causes the entire composite request to fail if any backend + // returns an empty response. Returns 502 Bad Gateway. + EmptyResponseFail EmptyResponsePolicy = "fail-on-empty" +) + +// PipelineRequestBuilder builds the HTTP request for the next pipeline stage. +// It receives: +// - ctx: the request context +// - originalReq: the original incoming HTTP request +// - previousResponses: accumulated parsed response bodies keyed by backend ID +// - nextBackendID: the ID of the next backend to call +// +// It returns the HTTP request to send to the next backend, or an error. +// If it returns nil for the request (with no error), the stage is skipped. +type PipelineRequestBuilder func( + ctx context.Context, + originalReq *http.Request, + previousResponses map[string][]byte, + nextBackendID string, +) (*http.Request, error) + +// PipelineResponseMerger merges all pipeline stage responses into a single HTTP response. +// It receives: +// - ctx: the request context +// - originalReq: the original incoming HTTP request +// - allResponses: all accumulated response bodies keyed by backend ID +// +// It returns the final merged HTTP response, or an error. +type PipelineResponseMerger func( + ctx context.Context, + originalReq *http.Request, + allResponses map[string][]byte, +) (*http.Response, error) + +// FanOutMerger merges parallel backend responses using custom logic such as +// ID-based matching, filtering, or complex data correlation. +// It receives: +// - ctx: the request context +// - originalReq: the original incoming HTTP request +// - responses: response bodies keyed by backend ID +// +// It returns the final merged HTTP response, or an error. +type FanOutMerger func( + ctx context.Context, + originalReq *http.Request, + responses map[string][]byte, +) (*http.Response, error) + +// PipelineConfig holds configuration for a pipeline strategy route. +type PipelineConfig struct { + // RequestBuilder constructs the request for each subsequent pipeline stage + // using responses from previous stages. + RequestBuilder PipelineRequestBuilder + + // ResponseMerger combines all pipeline stage responses into a final response. + // If nil, a default merger is used that wraps all responses in a JSON object + // keyed by backend ID. + ResponseMerger PipelineResponseMerger +} + +// isEmptyBody returns true if the body bytes represent an empty or null response. +func isEmptyBody(body []byte) bool { + if len(body) == 0 { + return true + } + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 { + return true + } + // Check for JSON null + if string(trimmed) == "null" { + return true + } + // Check for empty JSON object + if string(trimmed) == "{}" { + return true + } + // Check for empty JSON array + if string(trimmed) == "[]" { + return true + } + return false +} + +// executePipeline executes backends sequentially, passing each response to the +// PipelineRequestBuilder to construct the next request. +func (h *CompositeHandler) executePipeline(ctx context.Context, w http.ResponseWriter, r *http.Request, bodyBytes []byte) { + if h.pipelineConfig == nil || h.pipelineConfig.RequestBuilder == nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Pipeline strategy requires a PipelineConfig with RequestBuilder")) + return + } + + allResponses := make(map[string][]byte) + + for i, backend := range h.backends { + // Check the circuit breaker before making the request. + circuitBreaker := h.circuitBreakers[backend.ID] + if circuitBreaker != nil && circuitBreaker.IsOpen() { + continue + } + + var req *http.Request + var err error + + if i == 0 { + // First stage: use the original request + req, err = h.buildBackendRequest(ctx, backend, r, bodyBytes) + if err != nil { + if circuitBreaker != nil { + circuitBreaker.RecordFailure() + } + continue + } + } else { + // Subsequent stages: use the PipelineRequestBuilder + req, err = h.pipelineConfig.RequestBuilder(ctx, r, allResponses, backend.ID) + if err != nil { + if circuitBreaker != nil { + circuitBreaker.RecordFailure() + } + continue + } + // If builder returns nil, skip this stage + if req == nil { + continue + } + } + + // Execute the request using the backend's client + resp, err := backend.Client.Do(req) //nolint:gosec // G704: reverse proxy intentionally forwards requests to configured backends + if err != nil { + if circuitBreaker != nil { + circuitBreaker.RecordFailure() + } + continue + } + + // Read and store the response body + respBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + if circuitBreaker != nil { + circuitBreaker.RecordFailure() + } + continue + } + + // Record success in the circuit breaker. + if circuitBreaker != nil { + circuitBreaker.RecordSuccess() + } + + // Apply empty response policy + if isEmptyBody(respBody) { + switch h.emptyResponsePolicy { + case EmptyResponseFail: + w.WriteHeader(http.StatusBadGateway) + fmt.Fprintf(w, "Backend %s returned empty response", backend.ID) + return + case EmptyResponseSkip: + continue + case EmptyResponseAllow: + // Include empty response + default: + // Include empty response + } + } + + allResponses[backend.ID] = respBody + } + + // Merge all responses + if h.pipelineConfig.ResponseMerger != nil { + mergedResp, err := h.pipelineConfig.ResponseMerger(ctx, r, allResponses) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "Pipeline response merge failed: %v", err) + return + } + if mergedResp != nil { + h.writeResponse(mergedResp, w) + mergedResp.Body.Close() + return + } + } + + // Default: wrap all responses in a JSON object keyed by backend ID + h.writeDefaultPipelineResponse(allResponses, w) +} + +// executeFanOutMerge executes all backend requests in parallel, reads their bodies, +// then applies the FanOutMerger to produce the final response. +func (h *CompositeHandler) executeFanOutMerge(ctx context.Context, w http.ResponseWriter, r *http.Request, bodyBytes []byte) { + if h.fanOutMerger == nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Fan-out-merge strategy requires a FanOutMerger")) + return + } + + var wg sync.WaitGroup + var mu sync.Mutex + responses := make(map[string][]byte) + + for _, backend := range h.backends { + b := backend + wg.Go(func() { + // Check the circuit breaker + circuitBreaker := h.circuitBreakers[b.ID] + if circuitBreaker != nil && circuitBreaker.IsOpen() { + return + } + + // Execute the request + resp, err := h.executeBackendRequest(ctx, b, r, bodyBytes) //nolint:bodyclose // Response body is closed below + if err != nil { + if circuitBreaker != nil { + circuitBreaker.RecordFailure() + } + return + } + + // Read the response body + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + if circuitBreaker != nil { + circuitBreaker.RecordFailure() + } + return + } + + // Record success + if circuitBreaker != nil { + circuitBreaker.RecordSuccess() + } + + mu.Lock() + responses[b.ID] = body + mu.Unlock() + }) + } + + wg.Wait() + + // Short-circuit if all backends failed or were skipped by open circuit breakers + if len(responses) == 0 { + w.WriteHeader(http.StatusBadGateway) + fmt.Fprintf(w, "No successful responses from fan-out backends") + return + } + + // Apply empty response policy + filteredResponses := make(map[string][]byte) + for backendID, body := range responses { + if isEmptyBody(body) { + switch h.emptyResponsePolicy { + case EmptyResponseFail: + w.WriteHeader(http.StatusBadGateway) + fmt.Fprintf(w, "Backend %s returned empty response", backendID) + return + case EmptyResponseSkip: + continue + case EmptyResponseAllow: + filteredResponses[backendID] = body + default: + filteredResponses[backendID] = body + } + } else { + filteredResponses[backendID] = body + } + } + + // Short-circuit if all responses were filtered out (e.g., all empty with skip policy) + if len(filteredResponses) == 0 { + w.WriteHeader(http.StatusBadGateway) + fmt.Fprintf(w, "No non-empty responses from fan-out backends") + return + } + + // Apply the fan-out merger + mergedResp, err := h.fanOutMerger(ctx, r, filteredResponses) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "Fan-out merge failed: %v", err) + return + } + if mergedResp != nil { + h.writeResponse(mergedResp, w) + mergedResp.Body.Close() + return + } + + // If merger returned nil, return empty response + w.WriteHeader(http.StatusNoContent) +} + +// buildBackendRequest creates an HTTP request for a backend (used by pipeline for the first stage). +func (h *CompositeHandler) buildBackendRequest(ctx context.Context, backend *Backend, r *http.Request, bodyBytes []byte) (*http.Request, error) { + backendURL := backend.URL + r.URL.Path + if r.URL.RawQuery != "" { + backendURL += "?" + r.URL.RawQuery + } + + req, err := http.NewRequestWithContext(ctx, r.Method, backendURL, nil) //nolint:gosec // G704: reverse proxy intentionally forwards requests to configured backends + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + for k, v := range r.Header { + for _, val := range v { + req.Header.Add(k, val) + } + } + + if len(bodyBytes) > 0 { + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + req.ContentLength = int64(len(bodyBytes)) + } + + return req, nil +} + +// writeDefaultPipelineResponse writes a default JSON response containing all pipeline stage responses. +func (h *CompositeHandler) writeDefaultPipelineResponse(allResponses map[string][]byte, w http.ResponseWriter) { + if len(allResponses) == 0 { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("No successful responses from pipeline backends")) + return + } + + merged := make(map[string]interface{}) + for backendID, body := range allResponses { + var data interface{} + if err := json.Unmarshal(body, &data); err != nil { + merged[backendID] = string(body) + } else { + merged[backendID] = data + } + } + + encoded, err := json.Marshal(merged) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Failed to encode pipeline response")) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(encoded) +} + +// MakeJSONResponse is a helper that creates an HTTP response from a JSON-serializable value. +// It's provided for use by PipelineResponseMerger and FanOutMerger implementations. +func MakeJSONResponse(statusCode int, data interface{}) (*http.Response, error) { + body, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return &http.Response{ + Status: fmt.Sprintf("%d %s", statusCode, http.StatusText(statusCode)), + StatusCode: statusCode, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), + }, nil +} diff --git a/modules/reverseproxy/composite_pipeline_test.go b/modules/reverseproxy/composite_pipeline_test.go new file mode 100644 index 00000000..ad034ca5 --- /dev/null +++ b/modules/reverseproxy/composite_pipeline_test.go @@ -0,0 +1,1676 @@ +package reverseproxy + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================================ +// Pipeline Strategy Tests +// ============================================================================ + +func TestPipelineStrategy_BasicChaining(t *testing.T) { + // Backend A returns a list of conversation IDs + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "conversations": []map[string]interface{}{ + {"id": "conv-1", "title": "First conversation"}, + {"id": "conv-2", "title": "Second conversation"}, + {"id": "conv-3", "title": "Third conversation"}, + }, + }) + })) + defer backendA.Close() + + // Backend B returns follow-up details for given IDs (received via query params) + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ids := r.URL.Query().Get("ids") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + // Return follow-up details for the requested IDs + followUps := map[string]interface{}{} + for _, id := range strings.Split(ids, ",") { + if id == "conv-1" { + followUps[id] = map[string]interface{}{"is_followup": true, "original_id": "conv-0"} + } + // conv-2 and conv-3 have no follow-up data + } + json.NewEncoder(w).Encode(map[string]interface{}{ + "follow_ups": followUps, + }) + })) + defer backendB.Close() + + backends := []*Backend{ + {ID: "conversations", URL: backendA.URL, Client: http.DefaultClient}, + {ID: "followups", URL: backendB.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + if nextBackendID == "followups" { + // Parse conversation IDs from the previous response + var convResp struct { + Conversations []struct { + ID string `json:"id"` + } `json:"conversations"` + } + if convBody, ok := previousResponses["conversations"]; ok { + if err := json.Unmarshal(convBody, &convResp); err != nil { + return nil, fmt.Errorf("failed to parse conversations: %w", err) + } + } + + // Build query with IDs + ids := make([]string, 0, len(convResp.Conversations)) + for _, c := range convResp.Conversations { + ids = append(ids, c.ID) + } + + url := backends[1].URL + "/followups?ids=" + strings.Join(ids, ",") + return http.NewRequestWithContext(ctx, "GET", url, nil) + } + return nil, fmt.Errorf("unknown backend: %s", nextBackendID) + }, + ResponseMerger: func(ctx context.Context, originalReq *http.Request, allResponses map[string][]byte) (*http.Response, error) { + // Parse both responses + var convResp struct { + Conversations []map[string]interface{} `json:"conversations"` + } + if convBody, ok := allResponses["conversations"]; ok { + json.Unmarshal(convBody, &convResp) + } + + var followUpResp struct { + FollowUps map[string]interface{} `json:"follow_ups"` + } + if fuBody, ok := allResponses["followups"]; ok { + json.Unmarshal(fuBody, &followUpResp) + } + + // Merge follow-up data into conversations + for i, conv := range convResp.Conversations { + if id, ok := conv["id"].(string); ok { + if fu, exists := followUpResp.FollowUps[id]; exists { + convResp.Conversations[i]["follow_up"] = fu + } + } + } + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "conversations": convResp.Conversations, + }) + }, + }) + + req := httptest.NewRequest("GET", "/api/conversations", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(body, &result) + require.NoError(t, err) + + conversations, ok := result["conversations"].([]interface{}) + require.True(t, ok, "expected conversations array") + assert.Len(t, conversations, 3) + + // Verify conv-1 has follow-up data + conv1 := conversations[0].(map[string]interface{}) + assert.Equal(t, "conv-1", conv1["id"]) + followUp, hasFollowUp := conv1["follow_up"] + assert.True(t, hasFollowUp, "conv-1 should have follow_up data") + fuMap := followUp.(map[string]interface{}) + assert.Equal(t, true, fuMap["is_followup"]) + + // Verify conv-2 has no follow-up data + conv2 := conversations[1].(map[string]interface{}) + assert.Equal(t, "conv-2", conv2["id"]) + _, hasFollowUp2 := conv2["follow_up"] + assert.False(t, hasFollowUp2, "conv-2 should not have follow_up data") +} + +func TestPipelineStrategy_ThreeStageChain(t *testing.T) { + // Stage 1: returns user IDs + stage1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "users": []string{"user-1", "user-2"}, + }) + })) + defer stage1.Close() + + // Stage 2: returns user profiles + stage2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "profiles": map[string]interface{}{ + "user-1": map[string]interface{}{"name": "Alice", "dept": "eng"}, + "user-2": map[string]interface{}{"name": "Bob", "dept": "sales"}, + }, + }) + })) + defer stage2.Close() + + // Stage 3: returns permissions + stage3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "permissions": map[string]interface{}{ + "user-1": []string{"admin", "read", "write"}, + "user-2": []string{"read"}, + }, + }) + })) + defer stage3.Close() + + backends := []*Backend{ + {ID: "users", URL: stage1.URL, Client: http.DefaultClient}, + {ID: "profiles", URL: stage2.URL, Client: http.DefaultClient}, + {ID: "permissions", URL: stage3.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + switch nextBackendID { + case "profiles": + url := backends[1].URL + "/profiles" + return http.NewRequestWithContext(ctx, "GET", url, nil) + case "permissions": + url := backends[2].URL + "/permissions" + return http.NewRequestWithContext(ctx, "GET", url, nil) + } + return nil, fmt.Errorf("unknown backend: %s", nextBackendID) + }, + ResponseMerger: func(ctx context.Context, originalReq *http.Request, allResponses map[string][]byte) (*http.Response, error) { + result := make(map[string]interface{}) + for k, v := range allResponses { + var parsed interface{} + json.Unmarshal(v, &parsed) + result[k] = parsed + } + return MakeJSONResponse(http.StatusOK, result) + }, + }) + + req := httptest.NewRequest("GET", "/api/users", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + // All three stages should be present + assert.Contains(t, result, "users") + assert.Contains(t, result, "profiles") + assert.Contains(t, result, "permissions") +} + +func TestPipelineStrategy_NoPipelineConfig(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer backend.Close() + + backends := []*Backend{ + {ID: "b1", URL: backend.URL, Client: http.DefaultClient}, + } + handler := NewCompositeHandler(backends, StrategyPipeline, 5*time.Second) + // Intentionally not setting pipeline config + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) +} + +func TestPipelineStrategy_DefaultMerger(t *testing.T) { + // When no ResponseMerger is set, the default wraps responses by backend ID + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"step":"one"}`)) + })) + defer backend1.Close() + + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"step":"two"}`)) + })) + defer backend2.Close() + + backends := []*Backend{ + {ID: "step1", URL: backend1.URL, Client: http.DefaultClient}, + {ID: "step2", URL: backend2.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return http.NewRequestWithContext(ctx, "GET", backends[1].URL+"/step2", nil) + }, + // No ResponseMerger - uses default + }) + + req := httptest.NewRequest("GET", "/api/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + assert.Contains(t, result, "step1") + assert.Contains(t, result, "step2") +} + +func TestPipelineStrategy_SkipStage(t *testing.T) { + // When PipelineRequestBuilder returns nil, the stage is skipped + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"from-stage1"}`)) + })) + defer backend1.Close() + + callCount := 0 + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"from-stage2"}`)) + })) + defer backend2.Close() + + backends := []*Backend{ + {ID: "stage1", URL: backend1.URL, Client: http.DefaultClient}, + {ID: "stage2", URL: backend2.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + // Skip stage2 by returning nil + return nil, nil + }, + }) + + req := httptest.NewRequest("GET", "/api/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Stage 2 should not have been called + assert.Equal(t, 0, callCount, "stage2 should not have been called") + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + assert.Contains(t, result, "stage1") + assert.NotContains(t, result, "stage2") +} + +func TestPipelineStrategy_BackendError(t *testing.T) { + // First backend succeeds, second fails + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"from-stage1"}`)) + })) + defer backend1.Close() + + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer backend2.Close() + + backends := []*Backend{ + {ID: "stage1", URL: backend1.URL, Client: http.DefaultClient}, + {ID: "stage2", URL: backend2.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return http.NewRequestWithContext(ctx, "GET", backends[1].URL+"/test", nil) + }, + }) + + req := httptest.NewRequest("GET", "/api/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + + // Pipeline should still return stage1 results even if stage2 fails + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + assert.Contains(t, result, "stage1") +} + +func TestPipelineStrategy_RequestBuilderError(t *testing.T) { + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"ok"}`)) + })) + defer backend1.Close() + + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"stage2"}`)) + })) + defer backend2.Close() + + backends := []*Backend{ + {ID: "stage1", URL: backend1.URL, Client: http.DefaultClient}, + {ID: "stage2", URL: backend2.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return nil, fmt.Errorf("intentional builder error") + }, + }) + + req := httptest.NewRequest("GET", "/api/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + + // Should still return stage1 data despite stage2 builder error + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +// ============================================================================ +// Fan-Out-Merge Strategy Tests +// ============================================================================ + +func TestFanOutMerge_IDBasedMerging(t *testing.T) { + // Backend A returns conversations + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "items": []map[string]interface{}{ + {"id": "item-1", "name": "Item One", "status": "active"}, + {"id": "item-2", "name": "Item Two", "status": "pending"}, + {"id": "item-3", "name": "Item Three", "status": "active"}, + }, + }) + })) + defer backendA.Close() + + // Backend B returns ancillary details (some items may not be present) + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "details": map[string]interface{}{ + "item-1": map[string]interface{}{"priority": "high", "assignee": "Alice"}, + "item-3": map[string]interface{}{"priority": "low", "assignee": "Bob"}, + }, + }) + })) + defer backendB.Close() + + backends := []*Backend{ + {ID: "items", URL: backendA.URL, Client: http.DefaultClient}, + {ID: "details", URL: backendB.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + // Parse items + var itemsResp struct { + Items []map[string]interface{} `json:"items"` + } + if body, ok := responses["items"]; ok { + json.Unmarshal(body, &itemsResp) + } + + // Parse details + var detailsResp struct { + Details map[string]interface{} `json:"details"` + } + if body, ok := responses["details"]; ok { + json.Unmarshal(body, &detailsResp) + } + + // Merge by ID + for i, item := range itemsResp.Items { + if id, ok := item["id"].(string); ok { + if detail, exists := detailsResp.Details[id]; exists { + itemsResp.Items[i]["details"] = detail + } + } + } + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "items": itemsResp.Items, + }) + }) + + req := httptest.NewRequest("GET", "/api/items", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + items := result["items"].([]interface{}) + assert.Len(t, items, 3) + + // item-1 should have details + item1 := items[0].(map[string]interface{}) + assert.Equal(t, "item-1", item1["id"]) + assert.NotNil(t, item1["details"]) + + // item-2 should NOT have details + item2 := items[1].(map[string]interface{}) + assert.Equal(t, "item-2", item2["id"]) + _, hasDetails := item2["details"] + assert.False(t, hasDetails) + + // item-3 should have details + item3 := items[2].(map[string]interface{}) + assert.Equal(t, "item-3", item3["id"]) + assert.NotNil(t, item3["details"]) +} + +func TestFanOutMerge_FilterByAncillaryData(t *testing.T) { + // Backend A returns all conversations + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "conversations": []map[string]interface{}{ + {"id": "c1", "title": "Conversation 1"}, + {"id": "c2", "title": "Conversation 2"}, + {"id": "c3", "title": "Conversation 3"}, + }, + }) + })) + defer backendA.Close() + + // Backend B returns which conversations are follow-ups (acts as filter) + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "follow_up_ids": []string{"c1", "c3"}, + }) + })) + defer backendB.Close() + + backends := []*Backend{ + {ID: "conversations", URL: backendA.URL, Client: http.DefaultClient}, + {ID: "followups", URL: backendB.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + var convResp struct { + Conversations []map[string]interface{} `json:"conversations"` + } + if body, ok := responses["conversations"]; ok { + json.Unmarshal(body, &convResp) + } + + var fuResp struct { + FollowUpIDs []string `json:"follow_up_ids"` + } + if body, ok := responses["followups"]; ok { + json.Unmarshal(body, &fuResp) + } + + // Create lookup set + followUpSet := make(map[string]bool) + for _, id := range fuResp.FollowUpIDs { + followUpSet[id] = true + } + + // Filter: only include conversations that are follow-ups + var filtered []map[string]interface{} + for _, conv := range convResp.Conversations { + if id, ok := conv["id"].(string); ok && followUpSet[id] { + conv["is_follow_up"] = true + filtered = append(filtered, conv) + } + } + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "follow_up_conversations": filtered, + }) + }) + + req := httptest.NewRequest("GET", "/api/followups", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + convs := result["follow_up_conversations"].([]interface{}) + assert.Len(t, convs, 2, "only c1 and c3 should be included") + + ids := make([]string, 0, len(convs)) + for _, c := range convs { + ids = append(ids, c.(map[string]interface{})["id"].(string)) + } + assert.Contains(t, ids, "c1") + assert.Contains(t, ids, "c3") +} + +func TestFanOutMerge_NoMerger(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer backend.Close() + + backends := []*Backend{ + {ID: "b1", URL: backend.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 5*time.Second) + // Intentionally not setting fan-out merger + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) +} + +func TestFanOutMerge_ThreeBackends(t *testing.T) { + // Three backends returning different types of data for the same entity + backendUsers := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "users": []map[string]interface{}{ + {"id": "u1", "name": "Alice"}, + {"id": "u2", "name": "Bob"}, + }, + }) + })) + defer backendUsers.Close() + + backendRoles := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "roles": map[string]string{ + "u1": "admin", + "u2": "user", + }, + }) + })) + defer backendRoles.Close() + + backendActivity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "activity": map[string]int{ + "u1": 42, + "u2": 7, + }, + }) + })) + defer backendActivity.Close() + + backends := []*Backend{ + {ID: "users", URL: backendUsers.URL, Client: http.DefaultClient}, + {ID: "roles", URL: backendRoles.URL, Client: http.DefaultClient}, + {ID: "activity", URL: backendActivity.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + var usersResp struct { + Users []map[string]interface{} `json:"users"` + } + json.Unmarshal(responses["users"], &usersResp) + + var rolesResp struct { + Roles map[string]string `json:"roles"` + } + json.Unmarshal(responses["roles"], &rolesResp) + + var activityResp struct { + Activity map[string]float64 `json:"activity"` + } + json.Unmarshal(responses["activity"], &activityResp) + + // Enrich users with roles and activity + for i, user := range usersResp.Users { + id := user["id"].(string) + if role, ok := rolesResp.Roles[id]; ok { + usersResp.Users[i]["role"] = role + } + if count, ok := activityResp.Activity[id]; ok { + usersResp.Users[i]["activity_count"] = count + } + } + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "enriched_users": usersResp.Users, + }) + }) + + req := httptest.NewRequest("GET", "/api/users", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + users := result["enriched_users"].([]interface{}) + assert.Len(t, users, 2) + + u1 := users[0].(map[string]interface{}) + assert.Equal(t, "Alice", u1["name"]) + assert.Equal(t, "admin", u1["role"]) + assert.Equal(t, float64(42), u1["activity_count"]) + + u2 := users[1].(map[string]interface{}) + assert.Equal(t, "Bob", u2["name"]) + assert.Equal(t, "user", u2["role"]) + assert.Equal(t, float64(7), u2["activity_count"]) +} + +// ============================================================================ +// Empty Response Policy Tests +// ============================================================================ + +func TestEmptyResponsePolicy_AllowEmpty_Pipeline(t *testing.T) { + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"stage1"}`)) + })) + defer backend1.Close() + + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) // Empty JSON object + })) + defer backend2.Close() + + backends := []*Backend{ + {ID: "s1", URL: backend1.URL, Client: http.DefaultClient}, + {ID: "s2", URL: backend2.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetEmptyResponsePolicy(EmptyResponseAllow) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return http.NewRequestWithContext(ctx, "GET", backends[1].URL+"/test", nil) + }, + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + // Both stages should be present + assert.Contains(t, result, "s1") + assert.Contains(t, result, "s2") +} + +func TestEmptyResponsePolicy_SkipEmpty_Pipeline(t *testing.T) { + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"stage1"}`)) + })) + defer backend1.Close() + + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) // Empty JSON object + })) + defer backend2.Close() + + backends := []*Backend{ + {ID: "s1", URL: backend1.URL, Client: http.DefaultClient}, + {ID: "s2", URL: backend2.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetEmptyResponsePolicy(EmptyResponseSkip) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return http.NewRequestWithContext(ctx, "GET", backends[1].URL+"/test", nil) + }, + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + // Only s1 should be present; s2 was empty and skipped + assert.Contains(t, result, "s1") + assert.NotContains(t, result, "s2") +} + +func TestEmptyResponsePolicy_FailOnEmpty_Pipeline(t *testing.T) { + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"stage1"}`)) + })) + defer backend1.Close() + + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[]`)) // Empty JSON array + })) + defer backend2.Close() + + backends := []*Backend{ + {ID: "s1", URL: backend1.URL, Client: http.DefaultClient}, + {ID: "s2", URL: backend2.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetEmptyResponsePolicy(EmptyResponseFail) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return http.NewRequestWithContext(ctx, "GET", backends[1].URL+"/test", nil) + }, + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + + // Should fail because stage 2 returned empty + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) +} + +func TestEmptyResponsePolicy_SkipEmpty_FanOutMerge(t *testing.T) { + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "items": []string{"a", "b", "c"}, + }) + })) + defer backendA.Close() + + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`null`)) // Empty/null response + })) + defer backendB.Close() + + backends := []*Backend{ + {ID: "primary", URL: backendA.URL, Client: http.DefaultClient}, + {ID: "ancillary", URL: backendB.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetEmptyResponsePolicy(EmptyResponseSkip) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + // ancillary should be skipped + _, hasAncillary := responses["ancillary"] + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "has_ancillary": hasAncillary, + "backends": len(responses), + }) + }) + + req := httptest.NewRequest("GET", "/api/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + assert.Equal(t, false, result["has_ancillary"]) + assert.Equal(t, float64(1), result["backends"]) +} + +func TestEmptyResponsePolicy_FailOnEmpty_FanOutMerge(t *testing.T) { + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"ok"}`)) + })) + defer backendA.Close() + + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(``)) // Completely empty body + })) + defer backendB.Close() + + backends := []*Backend{ + {ID: "primary", URL: backendA.URL, Client: http.DefaultClient}, + {ID: "ancillary", URL: backendB.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetEmptyResponsePolicy(EmptyResponseFail) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + return MakeJSONResponse(http.StatusOK, responses) + }) + + req := httptest.NewRequest("GET", "/api/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) +} + +// ============================================================================ +// isEmptyBody Tests +// ============================================================================ + +func TestIsEmptyBody(t *testing.T) { + tests := []struct { + name string + body []byte + expected bool + }{ + {"nil body", nil, true}, + {"empty body", []byte{}, true}, + {"whitespace only", []byte(" \n\t "), true}, + {"null JSON", []byte("null"), true}, + {"empty object", []byte("{}"), true}, + {"empty array", []byte("[]"), true}, + {"null with whitespace", []byte(" null "), true}, + {"non-empty object", []byte(`{"key":"value"}`), false}, + {"non-empty array", []byte(`[1,2,3]`), false}, + {"string value", []byte(`"hello"`), false}, + {"number value", []byte(`42`), false}, + {"boolean true", []byte(`true`), false}, + {"object with empty string", []byte(`{"key":""}`), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isEmptyBody(tt.body) + assert.Equal(t, tt.expected, result) + }) + } +} + +// ============================================================================ +// MakeJSONResponse Helper Tests +// ============================================================================ + +func TestMakeJSONResponse(t *testing.T) { + data := map[string]interface{}{ + "key": "value", + "num": 42, + } + + resp, err := MakeJSONResponse(http.StatusOK, data) + require.NoError(t, err) + require.NotNil(t, resp) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) + + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var result map[string]interface{} + json.Unmarshal(body, &result) + assert.Equal(t, "value", result["key"]) + assert.Equal(t, float64(42), result["num"]) +} + +func TestMakeJSONResponse_CustomStatusCode(t *testing.T) { + resp, err := MakeJSONResponse(http.StatusCreated, map[string]string{"status": "created"}) + require.NoError(t, err) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + resp.Body.Close() +} + +// ============================================================================ +// Complex Scenario Tests +// ============================================================================ + +func TestPipelineStrategy_ConversationListWithFollowUps(t *testing.T) { + // Scenario from the issue: list page with queued conversations + // Backend A has general conversation details + // Backend B has ancillary details (follow-ups) + + conversationsBackend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "conversations": []map[string]interface{}{ + {"id": "c-100", "status": "queued", "counselor": "Alice", "created_at": "2024-01-01T10:00:00Z"}, + {"id": "c-101", "status": "queued", "counselor": "Bob", "created_at": "2024-01-01T10:05:00Z"}, + {"id": "c-102", "status": "active", "counselor": "Carol", "created_at": "2024-01-01T10:10:00Z"}, + {"id": "c-103", "status": "queued", "counselor": nil, "created_at": "2024-01-01T10:15:00Z"}, + }, + "total": 4, + "page": 1, + }) + })) + defer conversationsBackend.Close() + + followUpBackend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // This backend receives specific conversation IDs to check + idsParam := r.URL.Query().Get("conversation_ids") + ids := strings.Split(idsParam, ",") + + followUps := make(map[string]interface{}) + // c-100 is a follow-up to c-50 + for _, id := range ids { + if id == "c-100" { + followUps[id] = map[string]interface{}{ + "is_follow_up": true, + "original_conv_id": "c-50", + "follow_up_count": 2, + } + } + if id == "c-103" { + followUps[id] = map[string]interface{}{ + "is_follow_up": true, + "original_conv_id": "c-90", + "follow_up_count": 1, + } + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "follow_ups": followUps, + }) + })) + defer followUpBackend.Close() + + backends := []*Backend{ + {ID: "conversations", URL: conversationsBackend.URL, Client: http.DefaultClient}, + {ID: "followups", URL: followUpBackend.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + if nextBackendID == "followups" { + // Extract conversation IDs from the first response + var convResp struct { + Conversations []struct { + ID string `json:"id"` + } `json:"conversations"` + } + if body, ok := previousResponses["conversations"]; ok { + if err := json.Unmarshal(body, &convResp); err != nil { + return nil, err + } + } + + ids := make([]string, 0, len(convResp.Conversations)) + for _, c := range convResp.Conversations { + ids = append(ids, c.ID) + } + + url := followUpBackend.URL + "/followups?conversation_ids=" + strings.Join(ids, ",") + return http.NewRequestWithContext(ctx, "GET", url, nil) + } + return nil, fmt.Errorf("unknown backend: %s", nextBackendID) + }, + ResponseMerger: func(ctx context.Context, originalReq *http.Request, allResponses map[string][]byte) (*http.Response, error) { + var convResp struct { + Conversations []map[string]interface{} `json:"conversations"` + Total int `json:"total"` + Page int `json:"page"` + } + json.Unmarshal(allResponses["conversations"], &convResp) + + var fuResp struct { + FollowUps map[string]interface{} `json:"follow_ups"` + } + json.Unmarshal(allResponses["followups"], &fuResp) + + // Enrich conversations with follow-up data + for i, conv := range convResp.Conversations { + if id, ok := conv["id"].(string); ok { + if fu, exists := fuResp.FollowUps[id]; exists { + convResp.Conversations[i]["follow_up_info"] = fu + } + } + } + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "conversations": convResp.Conversations, + "total": convResp.Total, + "page": convResp.Page, + }) + }, + }) + + req := httptest.NewRequest("GET", "/api/conversations?status=queued", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + conversations := result["conversations"].([]interface{}) + assert.Len(t, conversations, 4) + + // c-100 should have follow_up_info + c100 := conversations[0].(map[string]interface{}) + assert.Equal(t, "c-100", c100["id"]) + fuInfo, hasFU := c100["follow_up_info"] + assert.True(t, hasFU) + fuMap := fuInfo.(map[string]interface{}) + assert.Equal(t, true, fuMap["is_follow_up"]) + assert.Equal(t, "c-50", fuMap["original_conv_id"]) + + // c-101 should NOT have follow_up_info + c101 := conversations[1].(map[string]interface{}) + _, hasFU101 := c101["follow_up_info"] + assert.False(t, hasFU101) + + // c-103 should have follow_up_info + c103 := conversations[3].(map[string]interface{}) + assert.Equal(t, "c-103", c103["id"]) + _, hasFU103 := c103["follow_up_info"] + assert.True(t, hasFU103) +} + +func TestFanOutMerge_ComplexNestedResponses(t *testing.T) { + // Complex scenario: merging nested JSON structures + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "orders": []map[string]interface{}{ + { + "id": "ord-1", + "amount": 99.99, + "items": []map[string]interface{}{ + {"sku": "SKU-001", "qty": 2}, + {"sku": "SKU-002", "qty": 1}, + }, + }, + { + "id": "ord-2", + "amount": 149.50, + "items": []map[string]interface{}{ + {"sku": "SKU-003", "qty": 3}, + }, + }, + }, + }) + })) + defer backendA.Close() + + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "shipping": map[string]interface{}{ + "ord-1": map[string]interface{}{ + "status": "shipped", + "tracking": "TRACK-12345", + "carrier": "FedEx", + }, + "ord-2": map[string]interface{}{ + "status": "processing", + "tracking": "", + "carrier": "", + }, + }, + }) + })) + defer backendB.Close() + + backends := []*Backend{ + {ID: "orders", URL: backendA.URL, Client: http.DefaultClient}, + {ID: "shipping", URL: backendB.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + var ordersResp struct { + Orders []map[string]interface{} `json:"orders"` + } + json.Unmarshal(responses["orders"], &ordersResp) + + var shippingResp struct { + Shipping map[string]interface{} `json:"shipping"` + } + json.Unmarshal(responses["shipping"], &shippingResp) + + for i, order := range ordersResp.Orders { + if id, ok := order["id"].(string); ok { + if shipping, exists := shippingResp.Shipping[id]; exists { + ordersResp.Orders[i]["shipping"] = shipping + } + } + } + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "orders": ordersResp.Orders, + }) + }) + + req := httptest.NewRequest("GET", "/api/orders", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + orders := result["orders"].([]interface{}) + assert.Len(t, orders, 2) + + ord1 := orders[0].(map[string]interface{}) + shipping := ord1["shipping"].(map[string]interface{}) + assert.Equal(t, "shipped", shipping["status"]) + assert.Equal(t, "TRACK-12345", shipping["tracking"]) +} + +func TestFanOutMerge_EmptyAncillaryData_AllowPolicy(t *testing.T) { + // Backend A returns data, Backend B returns empty (no ancillary data exists) + // With allow-empty policy, merger should handle gracefully + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "items": []map[string]interface{}{ + {"id": "x1", "name": "X1"}, + }, + }) + })) + defer backendA.Close() + + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) // Empty response - no ancillary data + })) + defer backendB.Close() + + backends := []*Backend{ + {ID: "primary", URL: backendA.URL, Client: http.DefaultClient}, + {ID: "ancillary", URL: backendB.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetEmptyResponsePolicy(EmptyResponseAllow) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + // Both responses should be present + _, hasPrimary := responses["primary"] + _, hasAncillary := responses["ancillary"] + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "primary_present": hasPrimary, + "ancillary_present": hasAncillary, + }) + }) + + req := httptest.NewRequest("GET", "/api/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + assert.Equal(t, true, result["primary_present"]) + assert.Equal(t, true, result["ancillary_present"]) +} + +func TestPipelineStrategy_WithRequestBody(t *testing.T) { + // Test pipeline with POST request containing body + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var input map[string]interface{} + json.Unmarshal(body, &input) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "processed": true, + "input": input, + "result_id": "res-123", + }) + })) + defer backend1.Close() + + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var input map[string]interface{} + json.Unmarshal(body, &input) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "stored": true, + "result_id": input["result_id"], + }) + })) + defer backend2.Close() + + backends := []*Backend{ + {ID: "process", URL: backend1.URL, Client: http.DefaultClient}, + {ID: "store", URL: backend2.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + if nextBackendID == "store" { + // Parse the result_id from process response + var processResp struct { + ResultID string `json:"result_id"` + } + json.Unmarshal(previousResponses["process"], &processResp) + + // Build store request with result_id + storeBody, _ := json.Marshal(map[string]interface{}{ + "result_id": processResp.ResultID, + "action": "save", + }) + req, _ := http.NewRequestWithContext(ctx, "POST", backends[1].URL+"/store", + bytes.NewReader(storeBody)) + req.Header.Set("Content-Type", "application/json") + return req, nil + } + return nil, fmt.Errorf("unknown backend: %s", nextBackendID) + }, + ResponseMerger: func(ctx context.Context, originalReq *http.Request, allResponses map[string][]byte) (*http.Response, error) { + result := make(map[string]interface{}) + for k, v := range allResponses { + var parsed interface{} + json.Unmarshal(v, &parsed) + result[k] = parsed + } + return MakeJSONResponse(http.StatusOK, result) + }, + }) + + inputBody := `{"data":"test-payload"}` + req := httptest.NewRequest("POST", "/api/process", strings.NewReader(inputBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + // Verify process stage + processResult := result["process"].(map[string]interface{}) + assert.Equal(t, true, processResult["processed"]) + assert.Equal(t, "res-123", processResult["result_id"]) + + // Verify store stage received the result_id from process stage + storeResult := result["store"].(map[string]interface{}) + assert.Equal(t, true, storeResult["stored"]) + assert.Equal(t, "res-123", storeResult["result_id"]) +} + +// ============================================================================ +// Module Integration Tests +// ============================================================================ + +func TestModuleSetPipelineConfig(t *testing.T) { + module := NewModule() + + config := PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return nil, nil + }, + } + + module.SetPipelineConfig("/api/pipeline", config) + assert.NotNil(t, module.pipelineConfigs["/api/pipeline"]) +} + +func TestModuleSetFanOutMerger(t *testing.T) { + module := NewModule() + + merger := func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + return MakeJSONResponse(http.StatusOK, responses) + } + + module.SetFanOutMerger("/api/fanout", merger) + assert.NotNil(t, module.fanOutMergers["/api/fanout"]) +} + +func TestModuleSetEmptyResponsePolicy(t *testing.T) { + module := NewModule() + + module.SetEmptyResponsePolicy("/api/test", EmptyResponseSkip) + assert.Equal(t, EmptyResponseSkip, module.emptyResponsePolicies["/api/test"]) +} + +// ============================================================================ +// Edge Case Tests +// ============================================================================ + +func TestPipeline_AllBackendsFail(t *testing.T) { + // When all backends fail, we should get a bad gateway + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer backend.Close() + + backends := []*Backend{ + {ID: "b1", URL: "http://localhost:1", Client: &http.Client{Timeout: 100 * time.Millisecond}}, // Will fail + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 5*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return nil, nil + }, + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) +} + +func TestFanOutMerge_SingleBackend(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"single": true}) + })) + defer backend.Close() + + backends := []*Backend{ + {ID: "only", URL: backend.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + var data interface{} + json.Unmarshal(responses["only"], &data) + return MakeJSONResponse(http.StatusOK, data) + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + assert.Equal(t, true, result["single"]) +} + +func TestFanOutMerge_MergerError(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer backend.Close() + + backends := []*Backend{ + {ID: "b1", URL: backend.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + return nil, fmt.Errorf("intentional merger error") + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) +} + +func TestPipelineStrategy_ResponseMergerError(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer backend.Close() + + backends := []*Backend{ + {ID: "b1", URL: backend.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return nil, nil + }, + ResponseMerger: func(ctx context.Context, originalReq *http.Request, allResponses map[string][]byte) (*http.Response, error) { + return nil, fmt.Errorf("intentional merger error") + }, + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) +} + +func TestFanOutMerge_AllBackendsFail_Returns502(t *testing.T) { + // When all backends fail (unreachable), executeFanOutMerge should return 502 + backends := []*Backend{ + {ID: "b1", URL: "http://127.0.0.1:1", Client: &http.Client{Timeout: 50 * time.Millisecond}}, + {ID: "b2", URL: "http://127.0.0.1:1", Client: &http.Client{Timeout: 50 * time.Millisecond}}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 5*time.Second) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + // Should never be called since all backends fail + return MakeJSONResponse(http.StatusOK, map[string]interface{}{"ok": true}) + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) +} + +func TestFanOutMerge_AllEmptyWithSkipPolicy_Returns502(t *testing.T) { + // When all responses are empty and skip-empty policy is set, return 502 + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`null`)) // Empty/null + })) + defer backendA.Close() + + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) // Empty object + })) + defer backendB.Close() + + backends := []*Backend{ + {ID: "a", URL: backendA.URL, Client: http.DefaultClient}, + {ID: "b", URL: backendB.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetEmptyResponsePolicy(EmptyResponseSkip) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + // Should never be called since all responses are skipped + return MakeJSONResponse(http.StatusOK, map[string]interface{}{"ok": true}) + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) +} + +func TestMakeJSONResponse_StatusFormat(t *testing.T) { + // Verify the status string is formatted per net/http conventions (e.g. "200 OK") + resp, err := MakeJSONResponse(http.StatusOK, map[string]interface{}{"ok": true}) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, "200 OK", resp.Status) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + resp2, err := MakeJSONResponse(http.StatusNotFound, map[string]interface{}{"error": "not found"}) + require.NoError(t, err) + defer resp2.Body.Close() + assert.Equal(t, "404 Not Found", resp2.Status) + assert.Equal(t, http.StatusNotFound, resp2.StatusCode) +} + +func TestCreateCompositeHandler_InvalidEmptyPolicy(t *testing.T) { + // createCompositeHandler should return an error for invalid empty_policy values + m := NewModule() + require.NotNil(t, m) + + cfg := &ReverseProxyConfig{ + DefaultBackend: "backend-a", + BackendServices: map[string]string{ + "backend-a": "http://localhost:9999", + "backend-b": "http://localhost:9998", + }, + CompositeRoutes: map[string]CompositeRoute{ + "/api/test": { + Pattern: "/api/test", + Backends: []string{"backend-a", "backend-b"}, + Strategy: "pipeline", + EmptyPolicy: "invalid-policy", + }, + }, + } + m.config = cfg + + route := cfg.CompositeRoutes["/api/test"] + _, err := m.createCompositeHandler(context.Background(), route, cfg) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidEmptyResponsePolicy) +} diff --git a/modules/reverseproxy/composite_test.go b/modules/reverseproxy/composite_test.go index 671ac67e..3008082f 100644 --- a/modules/reverseproxy/composite_test.go +++ b/modules/reverseproxy/composite_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/config.go b/modules/reverseproxy/config.go index 532feaca..6c8c15c8 100644 --- a/modules/reverseproxy/config.go +++ b/modules/reverseproxy/config.go @@ -96,6 +96,11 @@ type CompositeRoute struct { Backends []string `json:"backends" yaml:"backends" toml:"backends" env:"BACKENDS"` Strategy string `json:"strategy" yaml:"strategy" toml:"strategy" env:"STRATEGY"` + // EmptyPolicy defines how empty backend responses are handled. + // Valid values: "allow-empty" (default), "skip-empty", "fail-on-empty". + // This is used by pipeline and fan-out-merge strategies. + EmptyPolicy string `json:"empty_policy" yaml:"empty_policy" toml:"empty_policy" env:"EMPTY_POLICY"` + // FeatureFlagID is the ID of the feature flag that controls whether this composite route is enabled // If specified and the feature flag evaluates to false, this route will return 404 FeatureFlagID string `json:"feature_flag_id" yaml:"feature_flag_id" toml:"feature_flag_id" env:"FEATURE_FLAG_ID"` diff --git a/modules/reverseproxy/config_overwrite_reproduction_test.go b/modules/reverseproxy/config_overwrite_reproduction_test.go index f662b4b9..93c961e0 100644 --- a/modules/reverseproxy/config_overwrite_reproduction_test.go +++ b/modules/reverseproxy/config_overwrite_reproduction_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/debug.go b/modules/reverseproxy/debug.go index b1d91a83..a874892f 100644 --- a/modules/reverseproxy/debug.go +++ b/modules/reverseproxy/debug.go @@ -6,7 +6,7 @@ import ( "reflect" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // DebugEndpointsConfig provides configuration for debug endpoints. diff --git a/modules/reverseproxy/debug_service_init_test.go b/modules/reverseproxy/debug_service_init_test.go index 9840d2f6..5bdbb74c 100644 --- a/modules/reverseproxy/debug_service_init_test.go +++ b/modules/reverseproxy/debug_service_init_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" ) diff --git a/modules/reverseproxy/dry_run_bug_fixes_test.go b/modules/reverseproxy/dry_run_bug_fixes_test.go index 8df9cd1b..c57efb63 100644 --- a/modules/reverseproxy/dry_run_bug_fixes_test.go +++ b/modules/reverseproxy/dry_run_bug_fixes_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestDryRunBugFixes tests the specific bugs that were fixed in the dry-run feature: diff --git a/modules/reverseproxy/dry_run_issue_test.go b/modules/reverseproxy/dry_run_issue_test.go index 109c60f6..42ed5394 100644 --- a/modules/reverseproxy/dry_run_issue_test.go +++ b/modules/reverseproxy/dry_run_issue_test.go @@ -8,7 +8,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestDryRunIssue reproduces the exact issue described in the GitHub issue diff --git a/modules/reverseproxy/dryrun.go b/modules/reverseproxy/dryrun.go index 4ff9b33a..75816365 100644 --- a/modules/reverseproxy/dryrun.go +++ b/modules/reverseproxy/dryrun.go @@ -8,7 +8,7 @@ import ( "net/http" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // DryRunConfig provides configuration for dry-run functionality. diff --git a/modules/reverseproxy/duration_support_test.go b/modules/reverseproxy/duration_support_test.go index f54a5efa..fefd793e 100644 --- a/modules/reverseproxy/duration_support_test.go +++ b/modules/reverseproxy/duration_support_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/errors.go b/modules/reverseproxy/errors.go index baec962c..e9c4b151 100644 --- a/modules/reverseproxy/errors.go +++ b/modules/reverseproxy/errors.go @@ -40,8 +40,9 @@ var ( ErrNoSubjectForEventEmission = errors.New("no subject available for event emission") // Dynamic operation errors - ErrBackendIDRequired = errors.New("backend id required") - ErrServiceURLRequired = errors.New("service URL required") - ErrNoBackendsConfigured = errors.New("no backends configured") - ErrBackendNotConfigured = errors.New("backend not configured") + ErrBackendIDRequired = errors.New("backend id required") + ErrServiceURLRequired = errors.New("service URL required") + ErrNoBackendsConfigured = errors.New("no backends configured") + ErrBackendNotConfigured = errors.New("backend not configured") + ErrInvalidEmptyResponsePolicy = errors.New("invalid empty_policy: must be one of allow-empty, skip-empty, fail-on-empty") ) diff --git a/modules/reverseproxy/external_evaluator_fallback_bug_test.go b/modules/reverseproxy/external_evaluator_fallback_bug_test.go index 1b3cc61b..ea58cffa 100644 --- a/modules/reverseproxy/external_evaluator_fallback_bug_test.go +++ b/modules/reverseproxy/external_evaluator_fallback_bug_test.go @@ -5,7 +5,7 @@ import ( "net/http" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/mock" ) diff --git a/modules/reverseproxy/external_evaluator_fallback_integration_test.go b/modules/reverseproxy/external_evaluator_fallback_integration_test.go index 84ff1834..126098cf 100644 --- a/modules/reverseproxy/external_evaluator_fallback_integration_test.go +++ b/modules/reverseproxy/external_evaluator_fallback_integration_test.go @@ -5,7 +5,7 @@ import ( "net/http" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/mock" ) diff --git a/modules/reverseproxy/feature_flag_aggregator_bdd_test.go b/modules/reverseproxy/feature_flag_aggregator_bdd_test.go index 96334961..fd3037bd 100644 --- a/modules/reverseproxy/feature_flag_aggregator_bdd_test.go +++ b/modules/reverseproxy/feature_flag_aggregator_bdd_test.go @@ -8,7 +8,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/cucumber/godog" ) diff --git a/modules/reverseproxy/feature_flag_aggregator_test.go b/modules/reverseproxy/feature_flag_aggregator_test.go index c0df5309..ef8a6bf3 100644 --- a/modules/reverseproxy/feature_flag_aggregator_test.go +++ b/modules/reverseproxy/feature_flag_aggregator_test.go @@ -9,7 +9,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Mock evaluators for testing diff --git a/modules/reverseproxy/feature_flags.go b/modules/reverseproxy/feature_flags.go index 7275fa7e..c21a1cac 100644 --- a/modules/reverseproxy/feature_flags.go +++ b/modules/reverseproxy/feature_flags.go @@ -9,7 +9,7 @@ import ( "reflect" "sort" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // FeatureFlagEvaluator defines the interface for evaluating feature flags. diff --git a/modules/reverseproxy/feature_flags_test.go b/modules/reverseproxy/feature_flags_test.go index a29562fb..1e4cf2c7 100644 --- a/modules/reverseproxy/feature_flags_test.go +++ b/modules/reverseproxy/feature_flags_test.go @@ -7,7 +7,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestFileBasedFeatureFlagEvaluator_WithMockApp tests the feature flag evaluator with a mock application diff --git a/modules/reverseproxy/features/composite_pipeline.feature b/modules/reverseproxy/features/composite_pipeline.feature new file mode 100644 index 00000000..15565ae1 --- /dev/null +++ b/modules/reverseproxy/features/composite_pipeline.feature @@ -0,0 +1,37 @@ +Feature: Pipeline and Fan-Out-Merge Composite Strategies + As a developer building a multi-backend application + I want to chain backend requests and merge responses by ID + So that I can aggregate data from multiple services into unified responses + + Background: + Given I have a modular application with reverse proxy module configured + + Scenario: Pipeline strategy chains requests through multiple backends + Given I have a pipeline composite route with two backends + When I send a request to the pipeline route + Then the first backend should be called with the original request + And the second backend should receive data derived from the first response + And the final response should contain merged data from all stages + + Scenario: Fan-out-merge strategy merges responses by ID + Given I have a fan-out-merge composite route with two backends + When I send a request to the fan-out-merge route + Then both backends should be called in parallel + And the responses should be merged by matching IDs + And items with matching ancillary data should be enriched + + Scenario: Pipeline with empty response using skip policy + Given I have a pipeline route with skip-empty policy + When I send a request and a backend returns an empty response + Then the empty response should be excluded from the result + And the non-empty responses should still be present + + Scenario: Fan-out-merge with empty response using fail policy + Given I have a fan-out-merge route with fail-on-empty policy + When I send a request and a backend returns an empty response + Then the request should fail with a bad gateway error + + Scenario: Pipeline filters results using ancillary data + Given I have a pipeline route that filters by ancillary backend data + When I send a request to fetch filtered results + Then only items matching the ancillary criteria should be returned diff --git a/modules/reverseproxy/file_based_tenant_test.go b/modules/reverseproxy/file_based_tenant_test.go index 3f3a44dd..c3efa8ad 100644 --- a/modules/reverseproxy/file_based_tenant_test.go +++ b/modules/reverseproxy/file_based_tenant_test.go @@ -10,7 +10,7 @@ import ( "regexp" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/go.mod b/modules/reverseproxy/go.mod index b8175e8b..2037a71b 100644 --- a/modules/reverseproxy/go.mod +++ b/modules/reverseproxy/go.mod @@ -1,11 +1,11 @@ -module github.com/CrisisTextLine/modular/modules/reverseproxy/v2 +module github.com/GoCodeAlone/modular/modules/reverseproxy/v2 go 1.25 // retract (from old module path) v1.0.0 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/go-chi/chi/v5 v5.2.2 diff --git a/modules/reverseproxy/go.sum b/modules/reverseproxy/go.sum index 0438e88a..61e7b249 100644 --- a/modules/reverseproxy/go.sum +++ b/modules/reverseproxy/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= +github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/reverseproxy/health_endpoint_test.go b/modules/reverseproxy/health_endpoint_test.go index 78ae78e1..b040084a 100644 --- a/modules/reverseproxy/health_endpoint_test.go +++ b/modules/reverseproxy/health_endpoint_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestHealthEndpointNotProxied tests that health endpoints are not proxied to backends diff --git a/modules/reverseproxy/hostname_forwarding_test.go b/modules/reverseproxy/hostname_forwarding_test.go index ba07a448..7e0c71b9 100644 --- a/modules/reverseproxy/hostname_forwarding_test.go +++ b/modules/reverseproxy/hostname_forwarding_test.go @@ -9,7 +9,7 @@ import ( "net/url" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/integration_test.go b/modules/reverseproxy/integration_test.go index 5633f593..361b2a92 100644 --- a/modules/reverseproxy/integration_test.go +++ b/modules/reverseproxy/integration_test.go @@ -10,7 +10,7 @@ import ( "reflect" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Integration tests for the complete feature flag aggregator system diff --git a/modules/reverseproxy/mock_test.go b/modules/reverseproxy/mock_test.go index 5418999a..c78d6003 100644 --- a/modules/reverseproxy/mock_test.go +++ b/modules/reverseproxy/mock_test.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/go-chi/chi/v5" // Import chi for router type assertion ) diff --git a/modules/reverseproxy/mocks_for_test.go b/modules/reverseproxy/mocks_for_test.go index 590dab5d..44641f77 100644 --- a/modules/reverseproxy/mocks_for_test.go +++ b/modules/reverseproxy/mocks_for_test.go @@ -5,7 +5,7 @@ import ( "fmt" "net/http" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/mock" ) diff --git a/modules/reverseproxy/module.go b/modules/reverseproxy/module.go index 61c1cbac..437ae7f4 100644 --- a/modules/reverseproxy/module.go +++ b/modules/reverseproxy/module.go @@ -23,7 +23,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/gobwas/glob" ) @@ -73,6 +73,15 @@ type ReverseProxyModule struct { // Response transformers for composite routes (keyed by route pattern) responseTransformers map[string]ResponseTransformer + // Pipeline configurations for composite routes (keyed by route pattern) + pipelineConfigs map[string]*PipelineConfig + + // Fan-out merger functions for composite routes (keyed by route pattern) + fanOutMergers map[string]FanOutMerger + + // Empty response policies for composite routes (keyed by route pattern) + emptyResponsePolicies map[string]EmptyResponsePolicy + // Metrics collection metrics *MetricsCollector enableMetrics bool @@ -170,17 +179,20 @@ func NewModule() *ReverseProxyModule { // either in Constructor (if httpclient service is available) // or in Init (with default settings) module := &ReverseProxyModule{ - httpClient: nil, - backendProxies: make(map[string]*httputil.ReverseProxy), - backendRoutes: make(map[string]map[string]http.HandlerFunc), - compositeRoutes: make(map[string]http.HandlerFunc), - tenants: make(map[modular.TenantID]*ReverseProxyConfig), - tenantBackendProxies: make(map[modular.TenantID]map[string]*httputil.ReverseProxy), - preProxyTransforms: make(map[string]func(*http.Request)), - circuitBreakers: make(map[string]*CircuitBreaker), - enableMetrics: true, - loadBalanceCounters: make(map[string]int), - responseTransformers: make(map[string]ResponseTransformer), + httpClient: nil, + backendProxies: make(map[string]*httputil.ReverseProxy), + backendRoutes: make(map[string]map[string]http.HandlerFunc), + compositeRoutes: make(map[string]http.HandlerFunc), + tenants: make(map[modular.TenantID]*ReverseProxyConfig), + tenantBackendProxies: make(map[modular.TenantID]map[string]*httputil.ReverseProxy), + preProxyTransforms: make(map[string]func(*http.Request)), + circuitBreakers: make(map[string]*CircuitBreaker), + enableMetrics: true, + loadBalanceCounters: make(map[string]int), + responseTransformers: make(map[string]ResponseTransformer), + pipelineConfigs: make(map[string]*PipelineConfig), + fanOutMergers: make(map[string]FanOutMerger), + emptyResponsePolicies: make(map[string]EmptyResponsePolicy), } return module @@ -1565,6 +1577,27 @@ func (m *ReverseProxyModule) SetResponseTransformer(pattern string, transformer m.responseTransformers[pattern] = transformer } +// SetPipelineConfig sets the pipeline configuration for a specific composite route pattern. +// This is required for routes using the "pipeline" strategy. +// The PipelineConfig includes a RequestBuilder (to construct each subsequent request +// from previous responses) and an optional ResponseMerger (to assemble the final response). +func (m *ReverseProxyModule) SetPipelineConfig(pattern string, config PipelineConfig) { + m.pipelineConfigs[pattern] = &config +} + +// SetFanOutMerger sets the fan-out merger function for a specific composite route pattern. +// This is required for routes using the "fan-out-merge" strategy. +// The merger receives all parallel backend response bodies and produces a unified response. +func (m *ReverseProxyModule) SetFanOutMerger(pattern string, merger FanOutMerger) { + m.fanOutMergers[pattern] = merger +} + +// SetEmptyResponsePolicy sets the empty response policy for a specific composite route pattern. +// This controls how empty backend responses are handled in pipeline and fan-out-merge strategies. +func (m *ReverseProxyModule) SetEmptyResponsePolicy(pattern string, policy EmptyResponsePolicy) { + m.emptyResponsePolicies[pattern] = policy +} + // createReverseProxyForBackend creates a reverse proxy for a specific backend with per-backend configuration. func (m *ReverseProxyModule) createReverseProxyForBackend(ctx context.Context, target *url.URL, backendID string, endpoint string) *httputil.ReverseProxy { proxy := httputil.NewSingleHostReverseProxy(target) diff --git a/modules/reverseproxy/module_test.go b/modules/reverseproxy/module_test.go index d517f36b..e78349fd 100644 --- a/modules/reverseproxy/module_test.go +++ b/modules/reverseproxy/module_test.go @@ -15,7 +15,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/new_features_test.go b/modules/reverseproxy/new_features_test.go index dfd28ec6..e7d3c8e9 100644 --- a/modules/reverseproxy/new_features_test.go +++ b/modules/reverseproxy/new_features_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestNewFeatures tests the newly added features for debug endpoints and dry-run functionality diff --git a/modules/reverseproxy/response_header_rewriting_test.go b/modules/reverseproxy/response_header_rewriting_test.go index 8cb130f8..1924aeb9 100644 --- a/modules/reverseproxy/response_header_rewriting_test.go +++ b/modules/reverseproxy/response_header_rewriting_test.go @@ -8,7 +8,7 @@ import ( "net/url" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/route_configs_test.go b/modules/reverseproxy/route_configs_test.go index e5881044..265516eb 100644 --- a/modules/reverseproxy/route_configs_test.go +++ b/modules/reverseproxy/route_configs_test.go @@ -8,7 +8,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) func TestBasicRouteConfigsFeatureFlagRouting(t *testing.T) { diff --git a/modules/reverseproxy/routing_test.go b/modules/reverseproxy/routing_test.go index a7ce5236..ad817001 100644 --- a/modules/reverseproxy/routing_test.go +++ b/modules/reverseproxy/routing_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/modules/reverseproxy/service_dependency_test.go b/modules/reverseproxy/service_dependency_test.go index dae8c1ef..2cc0e8f7 100644 --- a/modules/reverseproxy/service_dependency_test.go +++ b/modules/reverseproxy/service_dependency_test.go @@ -4,7 +4,7 @@ import ( "net/http" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/service_exposure_test.go b/modules/reverseproxy/service_exposure_test.go index 76ed14f1..4a6cab05 100644 --- a/modules/reverseproxy/service_exposure_test.go +++ b/modules/reverseproxy/service_exposure_test.go @@ -8,7 +8,7 @@ import ( "reflect" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestFeatureFlagEvaluatorServiceExposure tests that the module exposes the feature flag evaluator as a service diff --git a/modules/reverseproxy/tenant_backend_test.go b/modules/reverseproxy/tenant_backend_test.go index 57dd424d..7ce78978 100644 --- a/modules/reverseproxy/tenant_backend_test.go +++ b/modules/reverseproxy/tenant_backend_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/modules/reverseproxy/tenant_composite_test.go b/modules/reverseproxy/tenant_composite_test.go index 15112865..8bdbcd62 100644 --- a/modules/reverseproxy/tenant_composite_test.go +++ b/modules/reverseproxy/tenant_composite_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/modules/reverseproxy/tenant_config_override_test.go b/modules/reverseproxy/tenant_config_override_test.go index be931377..e5d7c5e3 100644 --- a/modules/reverseproxy/tenant_config_override_test.go +++ b/modules/reverseproxy/tenant_config_override_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/modules/reverseproxy/tenant_default_backend_test.go b/modules/reverseproxy/tenant_default_backend_test.go index 1a3a2590..5cfb9455 100644 --- a/modules/reverseproxy/tenant_default_backend_test.go +++ b/modules/reverseproxy/tenant_default_backend_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/modules/reverseproxy/tenant_header_enforcement_simple_test.go b/modules/reverseproxy/tenant_header_enforcement_simple_test.go index 60e5ff49..3fc5a512 100644 --- a/modules/reverseproxy/tenant_header_enforcement_simple_test.go +++ b/modules/reverseproxy/tenant_header_enforcement_simple_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestTenantHeaderEnforcementSimple tests tenant header enforcement across all route types diff --git a/modules/reverseproxy/tenant_timeout_test.go b/modules/reverseproxy/tenant_timeout_test.go index 6d800c74..c7c2cf4f 100644 --- a/modules/reverseproxy/tenant_timeout_test.go +++ b/modules/reverseproxy/tenant_timeout_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/modules/scheduler/README.md b/modules/scheduler/README.md index 2d79a679..53c7f018 100644 --- a/modules/scheduler/README.md +++ b/modules/scheduler/README.md @@ -1,6 +1,6 @@ # Scheduler Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/scheduler.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/scheduler) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/scheduler.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/scheduler) The Scheduler Module provides job scheduling capabilities for Modular applications. It supports one-time and recurring jobs using cron syntax with comprehensive job history tracking. @@ -17,8 +17,8 @@ The Scheduler Module provides job scheduling capabilities for Modular applicatio ```go import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/scheduler" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/scheduler" ) // Register the scheduler module with your Modular application diff --git a/modules/scheduler/bdd_base_test.go b/modules/scheduler/bdd_base_test.go index 8416101d..1986e64f 100644 --- a/modules/scheduler/bdd_base_test.go +++ b/modules/scheduler/bdd_base_test.go @@ -7,7 +7,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/scheduler/bdd_core_lifecycle_test.go b/modules/scheduler/bdd_core_lifecycle_test.go index 013f6e08..317ac3e8 100644 --- a/modules/scheduler/bdd_core_lifecycle_test.go +++ b/modules/scheduler/bdd_core_lifecycle_test.go @@ -4,7 +4,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Core module lifecycle step implementations diff --git a/modules/scheduler/bdd_events_test.go b/modules/scheduler/bdd_events_test.go index 45a2b4ac..b7275840 100644 --- a/modules/scheduler/bdd_events_test.go +++ b/modules/scheduler/bdd_events_test.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Event observation step implementations diff --git a/modules/scheduler/bdd_persistence_test.go b/modules/scheduler/bdd_persistence_test.go index 86c1f7a7..01cd8c09 100644 --- a/modules/scheduler/bdd_persistence_test.go +++ b/modules/scheduler/bdd_persistence_test.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Persistence and recovery step implementations diff --git a/modules/scheduler/go.mod b/modules/scheduler/go.mod index 22407f25..7987652b 100644 --- a/modules/scheduler/go.mod +++ b/modules/scheduler/go.mod @@ -1,11 +1,11 @@ -module github.com/CrisisTextLine/modular/modules/scheduler +module github.com/GoCodeAlone/modular/modules/scheduler go 1.25 toolchain go1.25.0 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/google/uuid v1.6.0 diff --git a/modules/scheduler/go.sum b/modules/scheduler/go.sum index 99493e25..5c2673da 100644 --- a/modules/scheduler/go.sum +++ b/modules/scheduler/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= +github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/scheduler/module.go b/modules/scheduler/module.go index 8b577073..c97e5e33 100644 --- a/modules/scheduler/module.go +++ b/modules/scheduler/module.go @@ -63,7 +63,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/scheduler/module_test.go b/modules/scheduler/module_test.go index da0b9ea2..ea14f424 100644 --- a/modules/scheduler/module_test.go +++ b/modules/scheduler/module_test.go @@ -10,7 +10,7 @@ import ( "testing/synctest" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/scheduler/scheduler.go b/modules/scheduler/scheduler.go index cbe59209..1551bbf0 100644 --- a/modules/scheduler/scheduler.go +++ b/modules/scheduler/scheduler.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/google/uuid" "github.com/robfig/cron/v3" diff --git a/modules/scheduler/test_persistence_handler_test.go b/modules/scheduler/test_persistence_handler_test.go index aa4f7233..0ba00b4f 100644 --- a/modules/scheduler/test_persistence_handler_test.go +++ b/modules/scheduler/test_persistence_handler_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/nil_interface_panic_test.go b/nil_interface_panic_test.go index 51d84e70..00e07325 100644 --- a/nil_interface_panic_test.go +++ b/nil_interface_panic_test.go @@ -35,14 +35,14 @@ func TestTypeImplementsInterfaceWithNil(t *testing.T) { app := &StdApplication{} // Test with nil svcType (should not panic) - interfaceType := reflect.TypeOf((*NilTestInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[NilTestInterface]() result := app.typeImplementsInterface(nil, interfaceType) if result { t.Error("Expected false when svcType is nil") } // Test with nil interfaceType (should not panic) - svcType := reflect.TypeOf("") + svcType := reflect.TypeFor[string]() result = app.typeImplementsInterface(svcType, nil) if result { t.Error("Expected false when interfaceType is nil") @@ -68,7 +68,7 @@ func TestGetServicesByInterfaceWithNilService(t *testing.T) { } // This should not panic - interfaceType := reflect.TypeOf((*NilTestInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[NilTestInterface]() results := app.GetServicesByInterface(interfaceType) // Should return empty results, not panic @@ -117,7 +117,7 @@ func (m *interfaceConsumerModule) RequiresServices() []ServiceDependency { return []ServiceDependency{{ Name: "testService", MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*NilTestInterface)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[NilTestInterface](), Required: false, // Make it optional to avoid required service errors }} } diff --git a/observer.go b/observer.go index 5077919b..a8739b66 100644 --- a/observer.go +++ b/observer.go @@ -90,6 +90,22 @@ const ( EventTypeApplicationStarted = "com.modular.application.started" EventTypeApplicationStopped = "com.modular.application.stopped" EventTypeApplicationFailed = "com.modular.application.failed" + + // Tenant guard events + EventTypeTenantViolation = "com.modular.tenant.violation" + + // Configuration reload events + EventTypeConfigReloadStarted = "com.modular.config.reload.started" + EventTypeConfigReloadCompleted = "com.modular.config.reload.completed" + EventTypeConfigReloadFailed = "com.modular.config.reload.failed" + EventTypeConfigReloadNoop = "com.modular.config.reload.noop" + + // Health events + EventTypeHealthEvaluated = "com.modular.health.evaluated" + EventTypeHealthStatusChanged = "com.modular.health.status.changed" + + // Phase events + EventTypeAppPhaseChanged = "com.modular.application.phase.changed" ) // ObservableModule is an optional interface that modules can implement diff --git a/observer_cloudevents.go b/observer_cloudevents.go index 08dd5c30..67115d24 100644 --- a/observer_cloudevents.go +++ b/observer_cloudevents.go @@ -17,7 +17,7 @@ type CloudEvent = cloudevents.Event // NewCloudEvent creates a new CloudEvent with the specified parameters. // This is a convenience function for creating properly formatted CloudEvents. -func NewCloudEvent(eventType, source string, data interface{}, metadata map[string]interface{}) cloudevents.Event { +func NewCloudEvent(eventType, source string, data any, metadata map[string]any) cloudevents.Event { event := cloudevents.NewEvent() // Set required attributes @@ -58,12 +58,12 @@ type ModuleLifecyclePayload struct { // Timestamp is when the lifecycle action occurred (RFC3339 in JSON output). Timestamp time.Time `json:"timestamp"` // Additional arbitrary metadata (kept minimal; prefer evolving the struct if fields become first-class). - Metadata map[string]interface{} `json:"metadata,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` } // NewModuleLifecycleEvent builds a CloudEvent for a module/application lifecycle using the structured payload. // It sets payload_schema and module_action extensions for lightweight routing without full payload decode. -func NewModuleLifecycleEvent(source, subject, name, version, action string, metadata map[string]interface{}) cloudevents.Event { +func NewModuleLifecycleEvent(source, subject, name, version, action string, metadata map[string]any) cloudevents.Event { payload := ModuleLifecyclePayload{ Subject: subject, Name: name, diff --git a/observer_cloudevents_test.go b/observer_cloudevents_test.go index 7c39321c..4a2218d9 100644 --- a/observer_cloudevents_test.go +++ b/observer_cloudevents_test.go @@ -13,14 +13,14 @@ import ( // Mock types for testing type mockConfigProvider struct { - config interface{} + config any } -func (m *mockConfigProvider) GetConfig() interface{} { +func (m *mockConfigProvider) GetConfig() any { return m.config } -func (m *mockConfigProvider) GetDefaultConfig() interface{} { +func (m *mockConfigProvider) GetDefaultConfig() any { return m.config } @@ -32,28 +32,28 @@ type mockLogger struct { type mockLogEntry struct { Level string Message string - Args []interface{} + Args []any } -func (l *mockLogger) Info(msg string, args ...interface{}) { +func (l *mockLogger) Info(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, mockLogEntry{Level: "INFO", Message: msg, Args: args}) } -func (l *mockLogger) Error(msg string, args ...interface{}) { +func (l *mockLogger) Error(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, mockLogEntry{Level: "ERROR", Message: msg, Args: args}) } -func (l *mockLogger) Debug(msg string, args ...interface{}) { +func (l *mockLogger) Debug(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, mockLogEntry{Level: "DEBUG", Message: msg, Args: args}) } -func (l *mockLogger) Warn(msg string, args ...interface{}) { +func (l *mockLogger) Warn(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, mockLogEntry{Level: "WARN", Message: msg, Args: args}) @@ -72,8 +72,8 @@ func (m *mockModule) Init(app Application) error { } func TestNewCloudEvent(t *testing.T) { - data := map[string]interface{}{"test": "data"} - metadata := map[string]interface{}{"key": "value"} + data := map[string]any{"test": "data"} + metadata := map[string]any{"key": "value"} event := NewCloudEvent("test.event", "test.source", data, metadata) @@ -84,7 +84,7 @@ func TestNewCloudEvent(t *testing.T) { assert.False(t, event.Time().IsZero()) // Check data - var eventData map[string]interface{} + var eventData map[string]any err := event.DataAs(&eventData) require.NoError(t, err) assert.Equal(t, "data", eventData["test"]) diff --git a/observer_test.go b/observer_test.go index 5061ae69..a5b9f0d4 100644 --- a/observer_test.go +++ b/observer_test.go @@ -1,6 +1,7 @@ package modular import ( + "slices" "context" "errors" "testing" @@ -11,7 +12,7 @@ import ( func TestCloudEvent(t *testing.T) { t.Parallel() - metadata := map[string]interface{}{"key": "value"} + metadata := map[string]any{"key": "value"} event := NewCloudEvent( "test.event", "test.source", @@ -195,12 +196,9 @@ func (m *mockSubject) NotifyObservers(ctx context.Context, event cloudevents.Eve _ = registration.observer.OnEvent(ctx, event) } else { // Check if event type matches observer's interests - for _, eventType := range registration.eventTypes { - if eventType == event.Type() { + if slices.Contains(registration.eventTypes, event.Type()) { _ = registration.observer.OnEvent(ctx, event) - break } - } } } return nil diff --git a/parallel_init_test.go b/parallel_init_test.go new file mode 100644 index 00000000..35fce24a --- /dev/null +++ b/parallel_init_test.go @@ -0,0 +1,112 @@ +package modular + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +type parallelInitModule struct { + name string + deps []string + initDelay time.Duration + initCount *atomic.Int32 + maxPar *atomic.Int32 + curPar *atomic.Int32 +} + +func (m *parallelInitModule) Name() string { return m.name } +func (m *parallelInitModule) Dependencies() []string { return m.deps } +func (m *parallelInitModule) Init(app Application) error { + cur := m.curPar.Add(1) + defer m.curPar.Add(-1) + for { + old := m.maxPar.Load() + if cur <= old || m.maxPar.CompareAndSwap(old, cur) { + break + } + } + m.initCount.Add(1) + time.Sleep(m.initDelay) + return nil +} + +func TestWithParallelInit_ConcurrentSameDepth(t *testing.T) { + var initCount, maxPar, curPar atomic.Int32 + modA := ¶llelInitModule{name: "a", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + modB := ¶llelInitModule{name: "b", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + modC := ¶llelInitModule{name: "c", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB, modC), + WithParallelInit(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + start := time.Now() + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + elapsed := time.Since(start) + + if initCount.Load() != 3 { + t.Errorf("expected 3 inits, got %d", initCount.Load()) + } + if maxPar.Load() < 2 { + t.Errorf("expected at least 2 concurrent inits, got max %d", maxPar.Load()) + } + if elapsed > 120*time.Millisecond { + t.Errorf("expected parallel init to be faster, took %v", elapsed) + } +} + +func TestWithParallelInit_RespectsDepOrder(t *testing.T) { + var mu sync.Mutex + order := make([]string, 0) + + makeModule := func(name string, deps []string) *simpleOrderModule { + return &simpleOrderModule{name: name, deps: deps, order: &order, mu: &mu} + } + + modDep := makeModule("dep", nil) + modA := makeModule("a", []string{"dep"}) + modB := makeModule("b", []string{"dep"}) + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modDep, modA, modB), + WithParallelInit(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(order) != 3 || order[0] != "dep" { + t.Errorf("expected dep first, got order %v", order) + } +} + +type simpleOrderModule struct { + name string + deps []string + order *[]string + mu *sync.Mutex +} + +func (m *simpleOrderModule) Name() string { return m.name } +func (m *simpleOrderModule) Dependencies() []string { return m.deps } +func (m *simpleOrderModule) Init(app Application) error { + m.mu.Lock() + *m.order = append(*m.order, m.name) + m.mu.Unlock() + return nil +} diff --git a/phase.go b/phase.go new file mode 100644 index 00000000..9de22696 --- /dev/null +++ b/phase.go @@ -0,0 +1,38 @@ +package modular + +// AppPhase represents the current lifecycle phase of the application. +type AppPhase int32 + +const ( + PhaseCreated AppPhase = iota + PhaseInitializing + PhaseInitialized + PhaseStarting + PhaseRunning + PhaseDraining + PhaseStopping + PhaseStopped +) + +func (p AppPhase) String() string { + switch p { + case PhaseCreated: + return "created" + case PhaseInitializing: + return "initializing" + case PhaseInitialized: + return "initialized" + case PhaseStarting: + return "starting" + case PhaseRunning: + return "running" + case PhaseDraining: + return "draining" + case PhaseStopping: + return "stopping" + case PhaseStopped: + return "stopped" + default: + return "unknown" + } +} diff --git a/phase_test.go b/phase_test.go new file mode 100644 index 00000000..145f9eee --- /dev/null +++ b/phase_test.go @@ -0,0 +1,60 @@ +package modular + +import ( + "testing" +) + +func TestAppPhase_String(t *testing.T) { + tests := []struct { + phase AppPhase + want string + }{ + {PhaseCreated, "created"}, + {PhaseInitializing, "initializing"}, + {PhaseInitialized, "initialized"}, + {PhaseStarting, "starting"}, + {PhaseRunning, "running"}, + {PhaseDraining, "draining"}, + {PhaseStopping, "stopping"}, + {PhaseStopped, "stopped"}, + } + for _, tt := range tests { + if got := tt.phase.String(); got != tt.want { + t.Errorf("AppPhase(%d).String() = %q, want %q", tt.phase, got, tt.want) + } + } +} + +func TestPhaseTracking_LifecycleTransitions(t *testing.T) { + app, err := NewApplication(WithLogger(nopLogger{})) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + stdApp := app.(*StdApplication) + + if stdApp.Phase() != PhaseCreated { + t.Errorf("expected PhaseCreated, got %v", stdApp.Phase()) + } + + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if stdApp.Phase() != PhaseInitialized { + t.Errorf("expected PhaseInitialized after Init, got %v", stdApp.Phase()) + } + + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + if stdApp.Phase() != PhaseRunning { + t.Errorf("expected PhaseRunning after Start, got %v", stdApp.Phase()) + } + + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + if stdApp.Phase() != PhaseStopped { + t.Errorf("expected PhaseStopped after Stop, got %v", stdApp.Phase()) + } +} diff --git a/plugin.go b/plugin.go new file mode 100644 index 00000000..0976dc85 --- /dev/null +++ b/plugin.go @@ -0,0 +1,25 @@ +package modular + +// Plugin is the minimal interface for a plugin bundle that provides modules. +type Plugin interface { + Name() string + Modules() []Module +} + +// PluginWithHooks extends Plugin with initialization hooks. +type PluginWithHooks interface { + Plugin + InitHooks() []func(Application) error +} + +// PluginWithServices extends Plugin with service definitions. +type PluginWithServices interface { + Plugin + Services() []ServiceDefinition +} + +// ServiceDefinition describes a service provided by a plugin. +type ServiceDefinition struct { + Name string + Service any +} diff --git a/plugin_test.go b/plugin_test.go new file mode 100644 index 00000000..acd07798 --- /dev/null +++ b/plugin_test.go @@ -0,0 +1,89 @@ +package modular + +import "testing" + +type testPlugin struct { + modules []Module + services []ServiceDefinition + hookRan bool +} + +func (p *testPlugin) Name() string { return "test-plugin" } +func (p *testPlugin) Modules() []Module { return p.modules } +func (p *testPlugin) Services() []ServiceDefinition { return p.services } +func (p *testPlugin) InitHooks() []func(Application) error { + return []func(Application) error{ + func(app Application) error { + p.hookRan = true + return nil + }, + } +} + +type pluginTestModule struct { + name string + initialized bool +} + +func (m *pluginTestModule) Name() string { return m.name } +func (m *pluginTestModule) Init(app Application) error { m.initialized = true; return nil } + +func TestWithPlugins_RegistersModulesAndServices(t *testing.T) { + mod := &pluginTestModule{name: "plugin-mod"} + plugin := &testPlugin{ + modules: []Module{mod}, + services: []ServiceDefinition{{Name: "plugin.svc", Service: "hello"}}, + } + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithPlugins(plugin), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + if !mod.initialized { + t.Error("plugin module should have been initialized") + } + if !plugin.hookRan { + t.Error("plugin hook should have run") + } + + svc, err := GetTypedService[string](app, "plugin.svc") + if err != nil { + t.Fatalf("GetTypedService: %v", err) + } + if svc != "hello" { + t.Errorf("expected hello, got %s", svc) + } +} + +type simpleTestPlugin struct { + modules []Module +} + +func (p *simpleTestPlugin) Name() string { return "simple" } +func (p *simpleTestPlugin) Modules() []Module { return p.modules } + +func TestWithPlugins_SimplePlugin(t *testing.T) { + mod := &pluginTestModule{name: "simple-mod"} + plugin := &simpleTestPlugin{modules: []Module{mod}} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithPlugins(plugin), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if !mod.initialized { + t.Error("plugin module should have been initialized") + } +} diff --git a/reload.go b/reload.go new file mode 100644 index 00000000..bcfb1878 --- /dev/null +++ b/reload.go @@ -0,0 +1,167 @@ +package modular + +import ( + "fmt" + "strings" + "time" +) + +// ChangeType represents the type of configuration change. +type ChangeType int + +const ( + // ChangeAdded indicates a new configuration field was added. + ChangeAdded ChangeType = iota + // ChangeModified indicates an existing configuration field was modified. + ChangeModified + // ChangeRemoved indicates a configuration field was removed. + ChangeRemoved +) + +// String returns the string representation of a ChangeType. +func (ct ChangeType) String() string { + switch ct { + case ChangeAdded: + return "added" + case ChangeModified: + return "modified" + case ChangeRemoved: + return "removed" + default: + return "unknown" + } +} + +// ConfigChange represents a single configuration change detected during reload. +type ConfigChange struct { + Section string + FieldPath string + OldValue string + NewValue string + Source string +} + +// FieldChange represents a detailed field-level change with validation metadata. +type FieldChange struct { + OldValue any + NewValue any + FieldPath string + ChangeType ChangeType + IsSensitive bool + ValidationResult error +} + +// ConfigDiff represents the complete set of configuration changes between two states. +type ConfigDiff struct { + Changed map[string]FieldChange + Added map[string]FieldChange + Removed map[string]FieldChange + Timestamp time.Time + DiffID string +} + +// HasChanges reports whether the diff contains any changes. +func (d ConfigDiff) HasChanges() bool { + return len(d.Changed) > 0 || len(d.Added) > 0 || len(d.Removed) > 0 +} + +// FilterByPrefix returns a new ConfigDiff containing only changes whose field paths +// start with the given prefix. +func (d ConfigDiff) FilterByPrefix(prefix string) ConfigDiff { + filtered := ConfigDiff{ + Changed: make(map[string]FieldChange), + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + Timestamp: d.Timestamp, + DiffID: d.DiffID, + } + for k, v := range d.Changed { + if strings.HasPrefix(k, prefix) { + filtered.Changed[k] = v + } + } + for k, v := range d.Added { + if strings.HasPrefix(k, prefix) { + filtered.Added[k] = v + } + } + for k, v := range d.Removed { + if strings.HasPrefix(k, prefix) { + filtered.Removed[k] = v + } + } + return filtered +} + +// RedactSensitiveFields returns a copy of the diff with sensitive field values replaced +// by a redaction placeholder. +func (d ConfigDiff) RedactSensitiveFields() ConfigDiff { + redacted := ConfigDiff{ + Changed: make(map[string]FieldChange, len(d.Changed)), + Added: make(map[string]FieldChange, len(d.Added)), + Removed: make(map[string]FieldChange, len(d.Removed)), + Timestamp: d.Timestamp, + DiffID: d.DiffID, + } + redactMap := func(src map[string]FieldChange, dst map[string]FieldChange) { + for k, v := range src { + if v.IsSensitive { + v.OldValue = "[REDACTED]" + v.NewValue = "[REDACTED]" + } + dst[k] = v + } + } + redactMap(d.Changed, redacted.Changed) + redactMap(d.Added, redacted.Added) + redactMap(d.Removed, redacted.Removed) + return redacted +} + +// ChangeSummary returns a human-readable summary of all changes in the diff. +func (d ConfigDiff) ChangeSummary() string { + if !d.HasChanges() { + return "no changes" + } + var parts []string + if n := len(d.Added); n > 0 { + parts = append(parts, fmt.Sprintf("%d added", n)) + } + if n := len(d.Changed); n > 0 { + parts = append(parts, fmt.Sprintf("%d modified", n)) + } + if n := len(d.Removed); n > 0 { + parts = append(parts, fmt.Sprintf("%d removed", n)) + } + return strings.Join(parts, ", ") +} + +// ReloadTrigger indicates what initiated a configuration reload. +type ReloadTrigger int + +const ( + // ReloadManual indicates a reload triggered by an explicit API or CLI call. + ReloadManual ReloadTrigger = iota + // ReloadFileChange indicates a reload triggered by a file system change. + ReloadFileChange + // ReloadAPIRequest indicates a reload triggered by an API request. + ReloadAPIRequest + // ReloadScheduled indicates a reload triggered by a periodic schedule. + ReloadScheduled +) + +// String returns the string representation of a ReloadTrigger. +func (rt ReloadTrigger) String() string { + switch rt { + case ReloadManual: + return "manual" + case ReloadFileChange: + return "file_change" + case ReloadAPIRequest: + return "api_request" + case ReloadScheduled: + return "scheduled" + default: + return "unknown" + } +} diff --git a/reload_contract_bdd_test.go b/reload_contract_bdd_test.go new file mode 100644 index 00000000..1980efae --- /dev/null +++ b/reload_contract_bdd_test.go @@ -0,0 +1,475 @@ +package modular + +import ( + "slices" + "context" + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/cucumber/godog" +) + +// Static errors for reload contract BDD tests. +var ( + errExpectedModuleReceiveChanges = errors.New("expected module to receive changes") + errExpectedCompletedEvent = errors.New("expected reload completed event") + errExpectedFailedEvent = errors.New("expected reload failed event") + errExpectedNoopEvent = errors.New("expected reload noop event") + errExpectedModuleSkipped = errors.New("expected non-reloadable module to be skipped") + errExpectedOtherModulesReloaded = errors.New("expected other modules to still be reloaded") + errExpectedRollback = errors.New("expected first module to be rolled back") + errExpectedCircuitBreakerReject = errors.New("expected circuit breaker to reject request") + errExpectedCircuitBreakerReset = errors.New("expected circuit breaker to eventually reset") + errExpectedNoModuleCalls = errors.New("expected no modules to be called") + errExpectedRequestsProcessed = errors.New("expected all requests to be processed") +) + +// reloadBDDMockReloadable is a mock Reloadable for BDD reload contract tests. +type reloadBDDMockReloadable struct { + name string + canReload bool + timeout time.Duration + reloadErr error + reloadCalls atomic.Int32 + lastChanges []ConfigChange + mu sync.Mutex +} + +func (m *reloadBDDMockReloadable) Reload(_ context.Context, changes []ConfigChange) error { + m.reloadCalls.Add(1) + m.mu.Lock() + m.lastChanges = changes + m.mu.Unlock() + return m.reloadErr +} + +func (m *reloadBDDMockReloadable) CanReload() bool { return m.canReload } +func (m *reloadBDDMockReloadable) ReloadTimeout() time.Duration { return m.timeout } + +// reloadBDDSubject captures events for BDD reload contract tests. +type reloadBDDSubject struct { + mu sync.Mutex + events []cloudevents.Event +} + +func (s *reloadBDDSubject) RegisterObserver(_ Observer, _ ...string) error { return nil } +func (s *reloadBDDSubject) UnregisterObserver(_ Observer) error { return nil } +func (s *reloadBDDSubject) GetObservers() []ObserverInfo { return nil } +func (s *reloadBDDSubject) NotifyObservers(_ context.Context, event cloudevents.Event) error { + s.mu.Lock() + s.events = append(s.events, event) + s.mu.Unlock() + return nil +} + +func (s *reloadBDDSubject) eventTypes() []string { + s.mu.Lock() + defer s.mu.Unlock() + var types []string + for _, e := range s.events { + types = append(types, e.Type()) + } + return types +} + + + +// reloadBDDLogger implements Logger for BDD reload contract tests. +type reloadBDDLogger struct{} + +func (l *reloadBDDLogger) Info(_ string, _ ...any) {} +func (l *reloadBDDLogger) Error(_ string, _ ...any) {} +func (l *reloadBDDLogger) Warn(_ string, _ ...any) {} +func (l *reloadBDDLogger) Debug(_ string, _ ...any) {} + +// bddWaitForEvent polls until the subject has recorded an event of the given type, +// or the timeout elapses. Returns true if the event was observed. +func bddWaitForEvent(subject *reloadBDDSubject, eventType string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if slices.Contains(subject.eventTypes(), eventType) { + return true + } + time.Sleep(5 * time.Millisecond) + } + return false +} + +// bddWaitForCalls polls until the total reload calls across modules reaches +// at least n, or the timeout elapses. +func bddWaitForCalls(modules []*reloadBDDMockReloadable, n int32, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + var total int32 + for _, m := range modules { + total += m.reloadCalls.Load() + } + if total >= n { + return true + } + time.Sleep(5 * time.Millisecond) + } + return false +} + +// ReloadBDDContext holds state for reload contract BDD scenarios. +type ReloadBDDContext struct { + orchestrator *ReloadOrchestrator + modules []*reloadBDDMockReloadable + subject *reloadBDDSubject + logger *reloadBDDLogger + ctx context.Context + cancel context.CancelFunc + reloadErr error + raceDetected atomic.Bool +} + +func (rc *ReloadBDDContext) reset() { + if rc.cancel != nil { + rc.cancel() + } + rc.subject = &reloadBDDSubject{} + rc.logger = &reloadBDDLogger{} + rc.modules = nil + rc.reloadErr = nil + rc.raceDetected.Store(false) + rc.ctx, rc.cancel = context.WithCancel(context.Background()) +} + +func (rc *ReloadBDDContext) newDiff() ConfigDiff { + return ConfigDiff{ + Changed: map[string]FieldChange{ + "db.host": {OldValue: "localhost", NewValue: "remotehost", ChangeType: ChangeModified}, + }, + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + Timestamp: time.Now(), + DiffID: "bdd-test-diff", + } +} + +func (rc *ReloadBDDContext) emptyDiff() ConfigDiff { + return ConfigDiff{ + Changed: make(map[string]FieldChange), + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + DiffID: "bdd-empty-diff", + } +} + +// Step definitions + +func (rc *ReloadBDDContext) aReloadOrchestratorWithNReloadableModules(n int) error { + rc.orchestrator = NewReloadOrchestrator(rc.logger, rc.subject) + for i := range n { + mod := &reloadBDDMockReloadable{ + name: string(rune('a'+i)) + "_mod", + canReload: true, + timeout: 5 * time.Second, + } + rc.modules = append(rc.modules, mod) + rc.orchestrator.RegisterReloadable(mod.name, mod) + } + rc.orchestrator.Start(rc.ctx) + return nil +} + +func (rc *ReloadBDDContext) aReloadIsRequestedWithConfigurationChanges() error { + diff := rc.newDiff() + rc.reloadErr = rc.orchestrator.RequestReload(rc.ctx, ReloadManual, diff) + bddWaitForEvent(rc.subject, EventTypeConfigReloadCompleted, 2*time.Second) + return nil +} + +func (rc *ReloadBDDContext) allNModulesShouldReceiveTheChanges(n int) error { + received := 0 + for _, mod := range rc.modules { + if mod.reloadCalls.Load() > 0 { + received++ + } + } + if received != n { + return errExpectedModuleReceiveChanges + } + return nil +} + +func (rc *ReloadBDDContext) aReloadCompletedEventShouldBeEmitted() error { + if slices.Contains(rc.subject.eventTypes(), EventTypeConfigReloadCompleted) { + return nil + } + return errExpectedCompletedEvent +} + +func (rc *ReloadBDDContext) aReloadOrchestratorWithAModuleThatCannotReload() error { + rc.orchestrator = NewReloadOrchestrator(rc.logger, rc.subject) + + disabledMod := &reloadBDDMockReloadable{ + name: "disabled_mod", + canReload: false, + timeout: 5 * time.Second, + } + rc.modules = append(rc.modules, disabledMod) + rc.orchestrator.RegisterReloadable(disabledMod.name, disabledMod) + + enabledMod := &reloadBDDMockReloadable{ + name: "enabled_mod", + canReload: true, + timeout: 5 * time.Second, + } + rc.modules = append(rc.modules, enabledMod) + rc.orchestrator.RegisterReloadable(enabledMod.name, enabledMod) + + rc.orchestrator.Start(rc.ctx) + return nil +} + +func (rc *ReloadBDDContext) aReloadIsRequested() error { + diff := rc.newDiff() + rc.reloadErr = rc.orchestrator.RequestReload(rc.ctx, ReloadManual, diff) + // Wait for either completed or failed event (covers both success and failure scenarios). + bddWaitForEvent(rc.subject, EventTypeConfigReloadCompleted, 2*time.Second) + bddWaitForEvent(rc.subject, EventTypeConfigReloadFailed, 100*time.Millisecond) + return nil +} + +func (rc *ReloadBDDContext) theNonReloadableModuleShouldBeSkipped() error { + for _, mod := range rc.modules { + if !mod.canReload && mod.reloadCalls.Load() != 0 { + return errExpectedModuleSkipped + } + } + return nil +} + +func (rc *ReloadBDDContext) otherModulesShouldStillBeReloaded() error { + for _, mod := range rc.modules { + if mod.canReload && mod.reloadCalls.Load() == 0 { + return errExpectedOtherModulesReloaded + } + } + return nil +} + +func (rc *ReloadBDDContext) aReloadOrchestratorWith3ModulesWhereTheSecondFails() error { + rc.orchestrator = NewReloadOrchestrator(rc.logger, rc.subject) + + // Use names that sort deterministically to control ordering. + mod1 := &reloadBDDMockReloadable{ + name: "aaa_first", + canReload: true, + timeout: 5 * time.Second, + } + mod2 := &reloadBDDMockReloadable{ + name: "bbb_second", + canReload: true, + timeout: 5 * time.Second, + reloadErr: errors.New("reload failure"), + } + mod3 := &reloadBDDMockReloadable{ + name: "ccc_third", + canReload: true, + timeout: 5 * time.Second, + } + rc.modules = append(rc.modules, mod1, mod2, mod3) + rc.orchestrator.RegisterReloadable(mod1.name, mod1) + rc.orchestrator.RegisterReloadable(mod2.name, mod2) + rc.orchestrator.RegisterReloadable(mod3.name, mod3) + + rc.orchestrator.Start(rc.ctx) + return nil +} + +func (rc *ReloadBDDContext) theFirstModuleShouldBeRolledBack() error { + // Reload targets are sorted by name. aaa_first runs before bbb_second (which + // fails), so aaa_first is always applied and then rolled back (2 calls total). + mod1 := rc.modules[0] + calls := mod1.reloadCalls.Load() + if calls != 2 { + return fmt.Errorf("%w: expected aaa_first to be called 2 times (apply + rollback), got %d", errExpectedRollback, calls) + } + return nil +} + +func (rc *ReloadBDDContext) aReloadFailedEventShouldBeEmitted() error { + if slices.Contains(rc.subject.eventTypes(), EventTypeConfigReloadFailed) { + return nil + } + return errExpectedFailedEvent +} + +func (rc *ReloadBDDContext) aReloadOrchestratorWithAFailingModule() error { + rc.orchestrator = NewReloadOrchestrator(rc.logger, rc.subject) + + mod := &reloadBDDMockReloadable{ + name: "failing_mod", + canReload: true, + timeout: 5 * time.Second, + reloadErr: errors.New("always fails"), + } + rc.modules = append(rc.modules, mod) + rc.orchestrator.RegisterReloadable(mod.name, mod) + + rc.orchestrator.Start(rc.ctx) + return nil +} + +func (rc *ReloadBDDContext) nConsecutiveReloadsFail(n int) error { + diff := rc.newDiff() + for i := range n { + _ = rc.orchestrator.RequestReload(rc.ctx, ReloadManual, diff) + expected := int32(i + 1) + bddWaitForCalls(rc.modules, expected, 2*time.Second) + } + return nil +} + +func (rc *ReloadBDDContext) subsequentReloadRequestsShouldBeRejected() error { + diff := rc.newDiff() + err := rc.orchestrator.RequestReload(rc.ctx, ReloadManual, diff) + if err == nil || !strings.Contains(err.Error(), "circuit breaker") { + return errExpectedCircuitBreakerReject + } + return nil +} + +func (rc *ReloadBDDContext) theCircuitBreakerShouldEventuallyReset() error { + // Simulate that the backoff period has elapsed by moving lastFailure + // sufficiently into the past. This validates isCircuitOpen()/backoffDuration() + // rather than bypassing them. + rc.orchestrator.cbMu.Lock() + rc.orchestrator.lastFailure = time.Now().Add(-circuitBreakerMaxDelay - time.Second) + rc.orchestrator.cbMu.Unlock() + + diff := rc.newDiff() + err := rc.orchestrator.RequestReload(rc.ctx, ReloadManual, diff) + if err != nil && strings.Contains(err.Error(), "circuit breaker") { + return errExpectedCircuitBreakerReset + } + return nil +} + +func (rc *ReloadBDDContext) aReloadOrchestratorWithReloadableModules() error { + return rc.aReloadOrchestratorWithNReloadableModules(2) +} + +func (rc *ReloadBDDContext) aReloadIsRequestedWithNoChanges() error { + diff := rc.emptyDiff() + rc.reloadErr = rc.orchestrator.RequestReload(rc.ctx, ReloadManual, diff) + bddWaitForEvent(rc.subject, EventTypeConfigReloadNoop, 2*time.Second) + return nil +} + +func (rc *ReloadBDDContext) aReloadNoopEventShouldBeEmitted() error { + if slices.Contains(rc.subject.eventTypes(), EventTypeConfigReloadNoop) { + return nil + } + return errExpectedNoopEvent +} + +func (rc *ReloadBDDContext) noModulesShouldBeCalled() error { + for _, mod := range rc.modules { + if mod.reloadCalls.Load() != 0 { + return errExpectedNoModuleCalls + } + } + return nil +} + +func (rc *ReloadBDDContext) tenReloadRequestsAreSubmittedConcurrently() error { + diff := rc.newDiff() + var wg sync.WaitGroup + for range 10 { + wg.Go(func() { + _ = rc.orchestrator.RequestReload(rc.ctx, ReloadManual, diff) + }) + } + wg.Wait() + bddWaitForCalls(rc.modules, 1, 2*time.Second) + return nil +} + +func (rc *ReloadBDDContext) allRequestsShouldBeProcessed() error { + totalCalls := int32(0) + for _, mod := range rc.modules { + totalCalls += mod.reloadCalls.Load() + } + if totalCalls < 1 { + return errExpectedRequestsProcessed + } + return nil +} + +func (rc *ReloadBDDContext) noRaceConditionsShouldOccur() error { + // The race detector (go test -race) validates this at runtime. + // If we got here without a panic, there are no races. + return nil +} + +// InitializeReloadContractScenario wires up all reload contract BDD steps. +func InitializeReloadContractScenario(ctx *godog.ScenarioContext) { + rc := &ReloadBDDContext{} + + ctx.Before(func(ctx context.Context, _ *godog.Scenario) (context.Context, error) { + rc.reset() + return ctx, nil + }) + + ctx.After(func(ctx context.Context, _ *godog.Scenario, _ error) (context.Context, error) { + if rc.cancel != nil { + rc.cancel() + } + return ctx, nil + }) + + ctx.Step(`^a reload orchestrator with (\d+) reloadable modules$`, rc.aReloadOrchestratorWithNReloadableModules) + ctx.Step(`^a reload is requested with configuration changes$`, rc.aReloadIsRequestedWithConfigurationChanges) + ctx.Step(`^all (\d+) modules should receive the changes$`, rc.allNModulesShouldReceiveTheChanges) + ctx.Step(`^a reload completed event should be emitted$`, rc.aReloadCompletedEventShouldBeEmitted) + + ctx.Step(`^a reload orchestrator with a module that cannot reload$`, rc.aReloadOrchestratorWithAModuleThatCannotReload) + ctx.Step(`^a reload is requested$`, rc.aReloadIsRequested) + ctx.Step(`^the non-reloadable module should be skipped$`, rc.theNonReloadableModuleShouldBeSkipped) + ctx.Step(`^other modules should still be reloaded$`, rc.otherModulesShouldStillBeReloaded) + + ctx.Step(`^a reload orchestrator with 3 modules where the second fails$`, rc.aReloadOrchestratorWith3ModulesWhereTheSecondFails) + ctx.Step(`^the first module should be rolled back$`, rc.theFirstModuleShouldBeRolledBack) + ctx.Step(`^a reload failed event should be emitted$`, rc.aReloadFailedEventShouldBeEmitted) + + ctx.Step(`^a reload orchestrator with a failing module$`, rc.aReloadOrchestratorWithAFailingModule) + ctx.Step(`^(\d+) consecutive reloads fail$`, rc.nConsecutiveReloadsFail) + ctx.Step(`^subsequent reload requests should be rejected$`, rc.subsequentReloadRequestsShouldBeRejected) + ctx.Step(`^the circuit breaker should eventually reset$`, rc.theCircuitBreakerShouldEventuallyReset) + + ctx.Step(`^a reload orchestrator with reloadable modules$`, rc.aReloadOrchestratorWithReloadableModules) + ctx.Step(`^a reload is requested with no changes$`, rc.aReloadIsRequestedWithNoChanges) + ctx.Step(`^a reload noop event should be emitted$`, rc.aReloadNoopEventShouldBeEmitted) + ctx.Step(`^no modules should be called$`, rc.noModulesShouldBeCalled) + + ctx.Step(`^10 reload requests are submitted concurrently$`, rc.tenReloadRequestsAreSubmittedConcurrently) + ctx.Step(`^all requests should be processed$`, rc.allRequestsShouldBeProcessed) + ctx.Step(`^no race conditions should occur$`, rc.noRaceConditionsShouldOccur) +} + +// TestReloadContractBDD runs the BDD tests for the reload contract. +func TestReloadContractBDD(t *testing.T) { + suite := godog.TestSuite{ + ScenarioInitializer: InitializeReloadContractScenario, + Options: &godog.Options{ + Format: "pretty", + Paths: []string{"features/reload_contract.feature"}, + TestingT: t, + Strict: true, + }, + } + + if suite.Run() != 0 { + t.Fatal("non-zero status returned, failed to run reload contract feature tests") + } +} diff --git a/reload_integration_test.go b/reload_integration_test.go new file mode 100644 index 00000000..8dc6a47a --- /dev/null +++ b/reload_integration_test.go @@ -0,0 +1,78 @@ +package modular + +import ( + "context" + "sync/atomic" + "testing" + "time" +) + +type reloadableTestModule struct { + name string + reloadCount atomic.Int32 +} + +func (m *reloadableTestModule) Name() string { return m.name } +func (m *reloadableTestModule) Init(app Application) error { return nil } +func (m *reloadableTestModule) CanReload() bool { return true } +func (m *reloadableTestModule) ReloadTimeout() time.Duration { return 5 * time.Second } +func (m *reloadableTestModule) Reload(ctx context.Context, changes []ConfigChange) error { + m.reloadCount.Add(1) + return nil +} + +func TestWithDynamicReload_WiresOrchestrator(t *testing.T) { + mod := &reloadableTestModule{name: "hot-mod"} + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(mod), + WithDynamicReload(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + + stdApp := app.(*StdApplication) + diff := ConfigDiff{ + Changed: map[string]FieldChange{ + "key": {OldValue: "old", NewValue: "new", FieldPath: "key", ChangeType: ChangeModified}, + }, + DiffID: "test-diff", + } + if err := stdApp.RequestReload(context.Background(), ReloadManual, diff); err != nil { + t.Fatalf("RequestReload: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if mod.reloadCount.Load() > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + if mod.reloadCount.Load() == 0 { + t.Error("expected module to be reloaded") + } + + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } +} + +func TestRequestReload_WithoutDynamicReload(t *testing.T) { + app, err := NewApplication(WithLogger(nopLogger{})) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + stdApp := app.(*StdApplication) + err = stdApp.RequestReload(context.Background(), ReloadManual, ConfigDiff{}) + if err == nil { + t.Error("expected error when dynamic reload not enabled") + } +} diff --git a/reload_orchestrator.go b/reload_orchestrator.go new file mode 100644 index 00000000..3846b1c5 --- /dev/null +++ b/reload_orchestrator.go @@ -0,0 +1,393 @@ +package modular + +import ( + "context" + "fmt" + "sort" + "sync" + "sync/atomic" + "time" +) + +// ReloadRequest represents a pending configuration reload request. +type ReloadRequest struct { + Trigger ReloadTrigger + Diff ConfigDiff + Ctx context.Context +} + +// reloadEntry pairs a module name with its Reloadable implementation. +type reloadEntry struct { + name string + module Reloadable +} + +// defaultReloadTimeout is used when a module returns a non-positive ReloadTimeout. +const defaultReloadTimeout = 30 * time.Second + +// ReloadOrchestrator coordinates configuration reloading across all registered +// Reloadable modules. It provides single-flight execution, circuit breaking, +// rollback on partial failure, and event emission via the observer pattern. +// +// Note: Application-level integration (Application.RequestReload(), WithDynamicReload() +// builder option) will be added when the Application interface is extended in a follow-up. +type ReloadOrchestrator struct { + mu sync.RWMutex + reloadables map[string]Reloadable + + requestCh chan ReloadRequest + stopped atomic.Bool + stopOnce sync.Once + + processing atomic.Bool + + // Circuit breaker state + cbMu sync.Mutex + failures int + lastFailure time.Time + circuitOpen bool + + logger Logger + subject Subject +} + +// nopLogger is a no-op Logger used when nil is passed. +type nopLogger struct{} + +func (nopLogger) Info(_ string, _ ...any) {} +func (nopLogger) Error(_ string, _ ...any) {} +func (nopLogger) Warn(_ string, _ ...any) {} +func (nopLogger) Debug(_ string, _ ...any) {} + +// NewReloadOrchestrator creates a new ReloadOrchestrator with the given logger and event subject. +// If logger is nil, a no-op logger is used. +func NewReloadOrchestrator(logger Logger, subject Subject) *ReloadOrchestrator { + if logger == nil { + logger = nopLogger{} + } + return &ReloadOrchestrator{ + reloadables: make(map[string]Reloadable), + requestCh: make(chan ReloadRequest, 100), + logger: logger, + subject: subject, + } +} + +// RegisterReloadable registers a named module as reloadable. +func (o *ReloadOrchestrator) RegisterReloadable(name string, module Reloadable) { + o.mu.Lock() + defer o.mu.Unlock() + o.reloadables[name] = module +} + +// RequestReload enqueues a reload request. It returns an error if the orchestrator +// is stopped, the request channel is full, or the circuit breaker is open. +// +// The method is safe to call concurrently with Stop(). A recover guard protects +// against the send-on-closed-channel panic that can occur when Stop() closes +// requestCh between the stopped check and the channel send. +func (o *ReloadOrchestrator) RequestReload(ctx context.Context, trigger ReloadTrigger, diff ConfigDiff) (retErr error) { + if o.stopped.Load() { + return ErrReloadStopped + } + if o.isCircuitOpen() { + return ErrReloadCircuitBreakerOpen + } + + // Recover from a send on closed channel if Stop() races between the + // stopped check above and the channel send below. + defer func() { + if r := recover(); r != nil { + retErr = ErrReloadStopped + } + }() + + select { + case o.requestCh <- ReloadRequest{Trigger: trigger, Diff: diff, Ctx: ctx}: + return nil + default: + return ErrReloadChannelFull + } +} + +// Start begins the background goroutine that drains the reload request queue. +func (o *ReloadOrchestrator) Start(ctx context.Context) { + go func() { + for { + select { + case <-ctx.Done(): + return + case req, ok := <-o.requestCh: + if !ok { + return + } + o.handleReload(ctx, req) + } + } + }() +} + +// handleReload derives a properly scoped context for a single reload request and +// processes it. The context is cancelled immediately after processReload returns +// to avoid resource leaks from accumulated timers in the processing loop. +// +// The reload context is rooted in parentCtx (the Start context) so that stopping +// the orchestrator always cancels in-flight work. When the request carries its +// own context, both its deadline and cancellation are wired in: deadline via +// context.WithDeadline, and cancellation via a background goroutine that watches +// req.Ctx.Done(). This ensures callers who cancel req.Ctx abort the reload. +func (o *ReloadOrchestrator) handleReload(parentCtx context.Context, req ReloadRequest) { + rctx, cancel := context.WithCancel(parentCtx) + defer cancel() + + if req.Ctx != nil { + // Apply deadline if present. + if deadline, ok := req.Ctx.Deadline(); ok { + rctx, cancel = context.WithDeadline(rctx, deadline) //nolint:contextcheck // deadline from request + defer cancel() + } + + // Propagate cancellation from the request context. When req.Ctx is + // cancelled, cancel rctx so module Reload calls see cancellation. + go func() { + select { + case <-req.Ctx.Done(): + cancel() + case <-rctx.Done(): + // rctx already done (parent cancelled or reload finished); stop goroutine. + } + }() + } + + if err := o.processReload(rctx, req); err != nil { + o.logger.Error("Reload failed", "trigger", req.Trigger.String(), "error", err) + } +} + +// Stop signals the background goroutine to exit. It is safe to call multiple times. +func (o *ReloadOrchestrator) Stop() { + o.stopOnce.Do(func() { + o.stopped.Store(true) + close(o.requestCh) + }) +} + +// processReload executes a single reload request with atomic single-flight semantics, +// rollback on partial failure, and event emission. +func (o *ReloadOrchestrator) processReload(ctx context.Context, req ReloadRequest) error { + // Single-flight: only one reload at a time. + if !o.processing.CompareAndSwap(false, true) { + o.logger.Warn("Reload already in progress, skipping request") + return ErrReloadInProgress + } + defer o.processing.Store(false) + + // Noop if no changes — emit noop without a misleading "started" event. + if !req.Diff.HasChanges() { + o.emitEvent(ctx, EventTypeConfigReloadNoop, map[string]any{ + "trigger": req.Trigger.String(), + "diffId": req.Diff.DiffID, + }) + return nil + } + + // Emit started event only when there are actual changes to apply. + o.emitEvent(ctx, EventTypeConfigReloadStarted, map[string]any{ + "trigger": req.Trigger.String(), + "diffId": req.Diff.DiffID, + "summary": req.Diff.ChangeSummary(), + }) + + // Build the list of changes for the Reloadable interface. + changes := o.buildChanges(req.Diff) + + // Snapshot current reloadables under read lock, sorted by name for + // deterministic reload/rollback ordering. + o.mu.RLock() + targets := make([]reloadEntry, 0, len(o.reloadables)) + for name, mod := range o.reloadables { + targets = append(targets, reloadEntry{name: name, module: mod}) + } + o.mu.RUnlock() + + sort.Slice(targets, func(i, j int) bool { + return targets[i].name < targets[j].name + }) + + // Track which modules have been successfully reloaded (for rollback). + var applied []reloadEntry + + for _, t := range targets { + if !t.module.CanReload() { + o.logger.Info("Module cannot reload, skipping", "module", t.name) + continue + } + + timeout := t.module.ReloadTimeout() + if timeout <= 0 { + timeout = defaultReloadTimeout + } + rctx, cancel := context.WithTimeout(ctx, timeout) + + err := t.module.Reload(rctx, changes) + cancel() + + if err != nil { + o.logger.Error("Module reload failed, initiating rollback", + "module", t.name, "error", err) + + // Rollback already-applied modules in reverse order. + o.rollback(ctx, applied, changes) + + o.recordFailure() + o.emitEvent(ctx, EventTypeConfigReloadFailed, map[string]any{ + "trigger": req.Trigger.String(), + "diffId": req.Diff.DiffID, + "failedModule": t.name, + "error": err.Error(), + }) + return fmt.Errorf("reload failed at module %s: %w", t.name, err) + } + + applied = append(applied, t) + } + + o.recordSuccess() + o.emitEvent(ctx, EventTypeConfigReloadCompleted, map[string]any{ + "trigger": req.Trigger.String(), + "diffId": req.Diff.DiffID, + "modulesLoaded": len(applied), + }) + return nil +} + +// buildChanges converts a ConfigDiff into a flat slice of ConfigChange entries. +func (o *ReloadOrchestrator) buildChanges(diff ConfigDiff) []ConfigChange { + var changes []ConfigChange + for path, fc := range diff.Added { + changes = append(changes, ConfigChange{ + FieldPath: path, + OldValue: fmt.Sprintf("%v", fc.OldValue), + NewValue: fmt.Sprintf("%v", fc.NewValue), + Source: "diff", + }) + } + for path, fc := range diff.Changed { + changes = append(changes, ConfigChange{ + FieldPath: path, + OldValue: fmt.Sprintf("%v", fc.OldValue), + NewValue: fmt.Sprintf("%v", fc.NewValue), + Source: "diff", + }) + } + for path, fc := range diff.Removed { + changes = append(changes, ConfigChange{ + FieldPath: path, + OldValue: fmt.Sprintf("%v", fc.OldValue), + NewValue: fmt.Sprintf("%v", fc.NewValue), + Source: "diff", + }) + } + return changes +} + +// rollback attempts to reverse already-applied changes on modules in reverse order. +// This is best-effort: errors are logged but not propagated. +func (o *ReloadOrchestrator) rollback(ctx context.Context, applied []reloadEntry, originalChanges []ConfigChange) { + // Build reverse changes (swap old and new values). + reverseChanges := make([]ConfigChange, len(originalChanges)) + for i, c := range originalChanges { + reverseChanges[i] = ConfigChange{ + Section: c.Section, + FieldPath: c.FieldPath, + OldValue: c.NewValue, + NewValue: c.OldValue, + Source: "rollback", + } + } + + // Apply in reverse order. + for i := len(applied) - 1; i >= 0; i-- { + t := applied[i] + timeout := t.module.ReloadTimeout() + if timeout <= 0 { + timeout = defaultReloadTimeout + } + rctx, cancel := context.WithTimeout(ctx, timeout) + + if err := t.module.Reload(rctx, reverseChanges); err != nil { + o.logger.Error("Rollback failed for module", "module", t.name, "error", err) + } else { + o.logger.Info("Rollback succeeded for module", "module", t.name) + } + cancel() + } +} + +// emitEvent sends a CloudEvent via the configured subject. +func (o *ReloadOrchestrator) emitEvent(ctx context.Context, eventType string, data map[string]any) { + if o.subject == nil { + return + } + event := NewCloudEvent(eventType, "modular.reload.orchestrator", data, nil) + if err := o.subject.NotifyObservers(ctx, event); err != nil { + o.logger.Debug("Failed to emit reload event", "eventType", eventType, "error", err) + } +} + +// Circuit breaker methods. + +const ( + circuitBreakerThreshold = 3 + circuitBreakerBaseDelay = 2 * time.Second + circuitBreakerMaxDelay = 2 * time.Minute +) + +func (o *ReloadOrchestrator) isCircuitOpen() bool { + o.cbMu.Lock() + defer o.cbMu.Unlock() + if !o.circuitOpen { + return false + } + // Check if the backoff period has elapsed. + if time.Since(o.lastFailure) > o.backoffDuration() { + o.circuitOpen = false + o.logger.Info("Reload circuit breaker reset after backoff") + return false + } + return true +} + +func (o *ReloadOrchestrator) recordSuccess() { + o.cbMu.Lock() + defer o.cbMu.Unlock() + o.failures = 0 + o.circuitOpen = false +} + +func (o *ReloadOrchestrator) recordFailure() { + o.cbMu.Lock() + defer o.cbMu.Unlock() + o.failures++ + o.lastFailure = time.Now() + if o.failures >= circuitBreakerThreshold { + o.circuitOpen = true + o.logger.Warn("Reload circuit breaker opened", + "failures", o.failures, + "backoff", o.backoffDuration().String()) + } +} + +func (o *ReloadOrchestrator) backoffDuration() time.Duration { + if o.failures <= 0 { + return circuitBreakerBaseDelay + } + d := circuitBreakerBaseDelay + for i := 1; i < o.failures; i++ { + d *= 2 + if d > circuitBreakerMaxDelay { + return circuitBreakerMaxDelay + } + } + return d +} diff --git a/reload_test.go b/reload_test.go new file mode 100644 index 00000000..3b097a67 --- /dev/null +++ b/reload_test.go @@ -0,0 +1,457 @@ +package modular + +import ( + "slices" + "context" + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + cloudevents "github.com/cloudevents/sdk-go/v2" +) + +// mockReloadable is a test double for the Reloadable interface. +type mockReloadable struct { + canReload bool + timeout time.Duration + reloadErr error + reloadCalls atomic.Int32 + lastChanges []ConfigChange + mu sync.Mutex +} + +func (m *mockReloadable) Reload(_ context.Context, changes []ConfigChange) error { + m.reloadCalls.Add(1) + m.mu.Lock() + m.lastChanges = changes + m.mu.Unlock() + return m.reloadErr +} + +func (m *mockReloadable) CanReload() bool { return m.canReload } +func (m *mockReloadable) ReloadTimeout() time.Duration { return m.timeout } + + + +// reloadTestLogger implements Logger for testing. +type reloadTestLogger struct { + mu sync.Mutex + messages []string +} + +func (l *reloadTestLogger) Info(msg string, args ...any) { l.record("INFO", msg, args...) } +func (l *reloadTestLogger) Error(msg string, args ...any) { l.record("ERROR", msg, args...) } +func (l *reloadTestLogger) Warn(msg string, args ...any) { l.record("WARN", msg, args...) } +func (l *reloadTestLogger) Debug(msg string, args ...any) { l.record("DEBUG", msg, args...) } + +func (l *reloadTestLogger) record(level, msg string, args ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.messages = append(l.messages, fmt.Sprintf("[%s] %s %v", level, msg, args)) +} + +// reloadTestSubject is a minimal Subject for capturing events in reload tests. +type reloadTestSubject struct { + mu sync.Mutex + events []cloudevents.Event +} + +func (s *reloadTestSubject) RegisterObserver(_ Observer, _ ...string) error { return nil } +func (s *reloadTestSubject) UnregisterObserver(_ Observer) error { return nil } +func (s *reloadTestSubject) GetObservers() []ObserverInfo { return nil } +func (s *reloadTestSubject) NotifyObservers(_ context.Context, event cloudevents.Event) error { + s.mu.Lock() + s.events = append(s.events, event) + s.mu.Unlock() + return nil +} + + + +func (s *reloadTestSubject) eventTypes() []string { + s.mu.Lock() + defer s.mu.Unlock() + var types []string + for _, e := range s.events { + types = append(types, e.Type()) + } + return types +} + +// --- ConfigDiff tests --- + +func TestConfigDiff_HasChanges(t *testing.T) { + t.Run("empty diff", func(t *testing.T) { + d := ConfigDiff{ + Changed: make(map[string]FieldChange), + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + } + if d.HasChanges() { + t.Error("expected no changes") + } + }) + t.Run("with changed", func(t *testing.T) { + d := ConfigDiff{ + Changed: map[string]FieldChange{"a": {OldValue: 1, NewValue: 2}}, + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + } + if !d.HasChanges() { + t.Error("expected changes") + } + }) + t.Run("with added", func(t *testing.T) { + d := ConfigDiff{ + Changed: make(map[string]FieldChange), + Added: map[string]FieldChange{"b": {NewValue: "x"}}, + Removed: make(map[string]FieldChange), + } + if !d.HasChanges() { + t.Error("expected changes") + } + }) + t.Run("with removed", func(t *testing.T) { + d := ConfigDiff{ + Changed: make(map[string]FieldChange), + Added: make(map[string]FieldChange), + Removed: map[string]FieldChange{"c": {OldValue: "y"}}, + } + if !d.HasChanges() { + t.Error("expected changes") + } + }) +} + +func TestConfigDiff_FilterByPrefix(t *testing.T) { + d := ConfigDiff{ + Changed: map[string]FieldChange{ + "db.host": {OldValue: "old", NewValue: "new"}, + "db.port": {OldValue: 3306, NewValue: 5432}, + "cache.ttl": {OldValue: 30, NewValue: 60}, + }, + Added: map[string]FieldChange{ + "db.ssl": {NewValue: true}, + }, + Removed: map[string]FieldChange{ + "cache.max": {OldValue: 100}, + }, + } + + filtered := d.FilterByPrefix("db.") + if len(filtered.Changed) != 2 { + t.Errorf("expected 2 changed, got %d", len(filtered.Changed)) + } + if len(filtered.Added) != 1 { + t.Errorf("expected 1 added, got %d", len(filtered.Added)) + } + if len(filtered.Removed) != 0 { + t.Errorf("expected 0 removed, got %d", len(filtered.Removed)) + } + + cacheFiltered := d.FilterByPrefix("cache.") + if len(cacheFiltered.Changed) != 1 { + t.Errorf("expected 1 changed for cache prefix, got %d", len(cacheFiltered.Changed)) + } + if len(cacheFiltered.Removed) != 1 { + t.Errorf("expected 1 removed for cache prefix, got %d", len(cacheFiltered.Removed)) + } +} + +func TestConfigDiff_RedactSensitiveFields(t *testing.T) { + d := ConfigDiff{ + Changed: map[string]FieldChange{ + "db.password": {OldValue: "secret1", NewValue: "secret2", IsSensitive: true}, + "db.host": {OldValue: "old", NewValue: "new", IsSensitive: false}, + }, + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + } + + redacted := d.RedactSensitiveFields() + + pw := redacted.Changed["db.password"] + if pw.OldValue != "[REDACTED]" || pw.NewValue != "[REDACTED]" { + t.Errorf("sensitive field not redacted: old=%v new=%v", pw.OldValue, pw.NewValue) + } + + host := redacted.Changed["db.host"] + if host.OldValue != "old" || host.NewValue != "new" { + t.Errorf("non-sensitive field should not be redacted: old=%v new=%v", host.OldValue, host.NewValue) + } + + // Verify original is not mutated. + origPw := d.Changed["db.password"] + if origPw.OldValue != "secret1" { + t.Error("original diff should not be mutated") + } +} + +func TestConfigDiff_ChangeSummary(t *testing.T) { + t.Run("no changes", func(t *testing.T) { + d := ConfigDiff{ + Changed: make(map[string]FieldChange), + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + } + s := d.ChangeSummary() + if s != "no changes" { + t.Errorf("expected 'no changes', got %q", s) + } + }) + t.Run("mixed changes", func(t *testing.T) { + d := ConfigDiff{ + Changed: map[string]FieldChange{"a": {}}, + Added: map[string]FieldChange{"b": {}, "c": {}}, + Removed: map[string]FieldChange{"d": {}}, + } + s := d.ChangeSummary() + if !strings.Contains(s, "2 added") { + t.Errorf("summary missing added count: %q", s) + } + if !strings.Contains(s, "1 modified") { + t.Errorf("summary missing modified count: %q", s) + } + if !strings.Contains(s, "1 removed") { + t.Errorf("summary missing removed count: %q", s) + } + }) +} + +// waitFor polls cond every 5ms until it returns true or timeout elapses. +// Returns true if cond was satisfied, false on timeout. +func waitFor(t *testing.T, timeout time.Duration, cond func() bool) bool { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return true + } + time.Sleep(5 * time.Millisecond) + } + return false +} + +// --- ReloadOrchestrator tests --- + +func newTestDiff() ConfigDiff { + return ConfigDiff{ + Changed: map[string]FieldChange{ + "db.host": {OldValue: "localhost", NewValue: "remotehost", ChangeType: ChangeModified}, + }, + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + Timestamp: time.Now(), + DiffID: "test-diff-1", + } +} + +func TestReloadOrchestrator_SuccessfulReload(t *testing.T) { + logger := &reloadTestLogger{} + subject := &reloadTestSubject{} + orch := NewReloadOrchestrator(logger, subject) + + mod := &mockReloadable{canReload: true, timeout: 5 * time.Second} + orch.RegisterReloadable("testmod", mod) + + ctx := t.Context() + orch.Start(ctx) + + diff := newTestDiff() + if err := orch.RequestReload(ctx, ReloadManual, diff); err != nil { + t.Fatalf("RequestReload failed: %v", err) + } + + if !waitFor(t, 2*time.Second, func() bool { return mod.reloadCalls.Load() >= 1 }) { + t.Fatalf("timed out waiting for reload call, got %d", mod.reloadCalls.Load()) + } + + if !waitFor(t, 2*time.Second, func() bool { return len(subject.eventTypes()) >= 2 }) { + t.Fatalf("timed out waiting for events, got %d", len(subject.eventTypes())) + } + + events := subject.eventTypes() + if events[0] != EventTypeConfigReloadStarted { + t.Errorf("expected started event, got %s", events[0]) + } + if events[len(events)-1] != EventTypeConfigReloadCompleted { + t.Errorf("expected completed event, got %s", events[len(events)-1]) + } +} + +func TestReloadOrchestrator_PartialFailure_Rollback(t *testing.T) { + logger := &reloadTestLogger{} + subject := &reloadTestSubject{} + orch := NewReloadOrchestrator(logger, subject) + + mod1 := &mockReloadable{canReload: true, timeout: 5 * time.Second} + mod2 := &mockReloadable{canReload: true, timeout: 5 * time.Second, reloadErr: errors.New("boom")} + orch.RegisterReloadable("aaa_first", mod1) + orch.RegisterReloadable("zzz_second", mod2) + + ctx := t.Context() + orch.Start(ctx) + + diff := newTestDiff() + if err := orch.RequestReload(ctx, ReloadManual, diff); err != nil { + t.Fatalf("RequestReload failed: %v", err) + } + + if !waitFor(t, 2*time.Second, func() bool { + return len(subject.eventTypes()) > 0 && subject.eventTypes()[len(subject.eventTypes())-1] == EventTypeConfigReloadFailed + }) { + t.Fatal("timed out waiting for reload failure event") + } + + // Targets are sorted by name: aaa_first runs before zzz_second. + // aaa_first succeeds, then zzz_second fails, triggering rollback of aaa_first. + // So aaa_first gets 2 calls (apply + rollback) and zzz_second gets 1 call (the failure). + calls1 := mod1.reloadCalls.Load() + calls2 := mod2.reloadCalls.Load() + + if calls1 != 2 { + t.Errorf("expected aaa_first to be called 2 times (apply+rollback), got %d", calls1) + } + + if calls2 != 1 { + t.Errorf("expected zzz_second to be called 1 time (the failure), got %d", calls2) + } + + // Verify a failed event was emitted. + hasFailedEvent := false + for _, et := range subject.eventTypes() { + if et == EventTypeConfigReloadFailed { + hasFailedEvent = true + } + } + if !hasFailedEvent { + t.Error("expected ConfigReloadFailed event") + } +} + +func TestReloadOrchestrator_CircuitBreaker(t *testing.T) { + logger := &reloadTestLogger{} + subject := &reloadTestSubject{} + orch := NewReloadOrchestrator(logger, subject) + + failMod := &mockReloadable{canReload: true, timeout: 5 * time.Second, reloadErr: errors.New("fail")} + orch.RegisterReloadable("failing", failMod) + + ctx := t.Context() + orch.Start(ctx) + + diff := newTestDiff() + + // Trigger enough failures to open the circuit breaker. + for i := range circuitBreakerThreshold { + if err := orch.RequestReload(ctx, ReloadManual, diff); err != nil { + t.Fatalf("RequestReload %d failed: %v", i, err) + } + expected := int32(i + 1) + if !waitFor(t, 2*time.Second, func() bool { return failMod.reloadCalls.Load() >= expected }) { + t.Fatalf("timed out waiting for reload call %d", i+1) + } + } + + // Next request should be rejected by the circuit breaker. + err := orch.RequestReload(ctx, ReloadManual, diff) + if err == nil { + t.Error("expected circuit breaker error, got nil") + } + if err != nil && !strings.Contains(err.Error(), "circuit breaker") { + t.Errorf("expected circuit breaker error, got: %v", err) + } +} + +func TestReloadOrchestrator_CanReloadFalse_Skipped(t *testing.T) { + logger := &reloadTestLogger{} + subject := &reloadTestSubject{} + orch := NewReloadOrchestrator(logger, subject) + + mod := &mockReloadable{canReload: false, timeout: 5 * time.Second} + orch.RegisterReloadable("disabled", mod) + + ctx := t.Context() + orch.Start(ctx) + + diff := newTestDiff() + if err := orch.RequestReload(ctx, ReloadManual, diff); err != nil { + t.Fatalf("RequestReload failed: %v", err) + } + + if !waitFor(t, 2*time.Second, func() bool { + return slices.Contains(subject.eventTypes(), EventTypeConfigReloadCompleted) + }) { + t.Fatal("timed out waiting for ConfigReloadCompleted event") + } + + if mod.reloadCalls.Load() != 0 { + t.Errorf("expected 0 reload calls for disabled module, got %d", mod.reloadCalls.Load()) + } +} + +func TestReloadOrchestrator_ConcurrentRequests(t *testing.T) { + logger := &reloadTestLogger{} + subject := &reloadTestSubject{} + orch := NewReloadOrchestrator(logger, subject) + + mod := &mockReloadable{canReload: true, timeout: 5 * time.Second} + orch.RegisterReloadable("concurrent", mod) + + ctx := t.Context() + orch.Start(ctx) + + diff := newTestDiff() + + var wg sync.WaitGroup + for range 10 { + wg.Go(func() { + _ = orch.RequestReload(ctx, ReloadManual, diff) + }) + } + wg.Wait() + + if !waitFor(t, 2*time.Second, func() bool { return mod.reloadCalls.Load() >= 1 }) { + t.Fatalf("timed out waiting for at least 1 reload call, got %d", mod.reloadCalls.Load()) + } + + calls := mod.reloadCalls.Load() + // Due to single-flight, some may be skipped — that's expected. + t.Logf("concurrent test: %d reload calls processed out of 10 requests", calls) +} + +func TestReloadOrchestrator_NoopOnEmptyDiff(t *testing.T) { + logger := &reloadTestLogger{} + subject := &reloadTestSubject{} + orch := NewReloadOrchestrator(logger, subject) + + mod := &mockReloadable{canReload: true, timeout: 5 * time.Second} + orch.RegisterReloadable("mod", mod) + + ctx := t.Context() + orch.Start(ctx) + + emptyDiff := ConfigDiff{ + Changed: make(map[string]FieldChange), + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + DiffID: "empty", + } + if err := orch.RequestReload(ctx, ReloadManual, emptyDiff); err != nil { + t.Fatalf("RequestReload failed: %v", err) + } + + if !waitFor(t, 2*time.Second, func() bool { + return slices.Contains(subject.eventTypes(), EventTypeConfigReloadNoop) + }) { + t.Fatal("timed out waiting for ConfigReloadNoop event") + } + + if mod.reloadCalls.Load() != 0 { + t.Errorf("expected 0 reload calls for empty diff, got %d", mod.reloadCalls.Load()) + } +} diff --git a/secret_resolver.go b/secret_resolver.go new file mode 100644 index 00000000..da078a3d --- /dev/null +++ b/secret_resolver.go @@ -0,0 +1,75 @@ +package modular + +import ( + "context" + "fmt" + "regexp" +) + +// SecretResolver resolves secret references in configuration values. +type SecretResolver interface { + ResolveSecret(ctx context.Context, ref string) (string, error) + CanResolve(ref string) bool +} + +var secretRefPattern = regexp.MustCompile(`^\$\{([^:}]+:[^}]+)\}$`) + +// ExpandSecrets walks a config map and replaces string values matching +// ${prefix:path} with the resolved secret value. Recurses into nested maps. +func ExpandSecrets(ctx context.Context, config map[string]any, resolvers ...SecretResolver) error { + for key, val := range config { + switch v := val.(type) { + case string: + resolved, err := resolveSecretString(ctx, v, resolvers) + if err != nil { + return fmt.Errorf("resolving %q: %w", key, err) + } + config[key] = resolved + case map[string]any: + if err := ExpandSecrets(ctx, v, resolvers...); err != nil { + return err + } + case []any: + if err := expandSecretsSlice(ctx, v, resolvers); err != nil { + return fmt.Errorf("resolving %q: %w", key, err) + } + } + } + return nil +} + +func expandSecretsSlice(ctx context.Context, slice []any, resolvers []SecretResolver) error { + for i, elem := range slice { + switch v := elem.(type) { + case string: + resolved, err := resolveSecretString(ctx, v, resolvers) + if err != nil { + return err + } + slice[i] = resolved + case map[string]any: + if err := ExpandSecrets(ctx, v, resolvers...); err != nil { + return err + } + case []any: + if err := expandSecretsSlice(ctx, v, resolvers); err != nil { + return err + } + } + } + return nil +} + +func resolveSecretString(ctx context.Context, val string, resolvers []SecretResolver) (string, error) { + match := secretRefPattern.FindStringSubmatch(val) + if match == nil { + return val, nil + } + ref := match[1] + for _, r := range resolvers { + if r.CanResolve(ref) { + return r.ResolveSecret(ctx, ref) + } + } + return val, nil +} diff --git a/secret_resolver_test.go b/secret_resolver_test.go new file mode 100644 index 00000000..72e7cb81 --- /dev/null +++ b/secret_resolver_test.go @@ -0,0 +1,67 @@ +package modular + +import ( + "context" + "strings" + "testing" +) + +type mockSecretResolver struct { + prefix string + values map[string]string +} + +func (r *mockSecretResolver) CanResolve(ref string) bool { + return strings.HasPrefix(ref, r.prefix+":") +} + +func (r *mockSecretResolver) ResolveSecret(ctx context.Context, ref string) (string, error) { + key := strings.TrimPrefix(ref, r.prefix+":") + if v, ok := r.values[key]; ok { + return v, nil + } + return "", ErrServiceNotFound +} + +func TestExpandSecrets_ResolvesRefs(t *testing.T) { + resolver := &mockSecretResolver{ + prefix: "vault", + values: map[string]string{"secret/db-pass": "s3cret"}, + } + config := map[string]any{ + "host": "localhost", + "password": "${vault:secret/db-pass}", + "nested": map[string]any{"key": "${vault:secret/db-pass}"}, + } + if err := ExpandSecrets(context.Background(), config, resolver); err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + if config["password"] != "s3cret" { + t.Errorf("expected s3cret, got %v", config["password"]) + } + nested := config["nested"].(map[string]any) + if nested["key"] != "s3cret" { + t.Errorf("expected nested s3cret, got %v", nested["key"]) + } +} + +func TestExpandSecrets_SkipsNonRefs(t *testing.T) { + config := map[string]any{"host": "localhost", "port": 5432} + if err := ExpandSecrets(context.Background(), config); err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + if config["host"] != "localhost" { + t.Errorf("expected localhost, got %v", config["host"]) + } +} + +func TestExpandSecrets_NoMatchingResolver(t *testing.T) { + config := map[string]any{"password": "${aws:secret/key}"} + resolver := &mockSecretResolver{prefix: "vault", values: map[string]string{}} + if err := ExpandSecrets(context.Background(), config, resolver); err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + if config["password"] != "${aws:secret/key}" { + t.Errorf("expected unchanged ref, got %v", config["password"]) + } +} diff --git a/service.go b/service.go index eb81a156..f2f2c997 100644 --- a/service.go +++ b/service.go @@ -3,6 +3,7 @@ package modular import ( "fmt" "reflect" + "sync" ) // ServiceRegistry allows registration and retrieval of services by name. @@ -35,6 +36,8 @@ type ServiceRegistryEntry struct { // EnhancedServiceRegistry provides enhanced service registry functionality // that tracks module associations and handles automatic conflict resolution. type EnhancedServiceRegistry struct { + mu sync.RWMutex + // services maps service names to their registry entries services map[string]*ServiceRegistryEntry @@ -46,6 +49,9 @@ type EnhancedServiceRegistry struct { // currentModule tracks the module currently being initialized currentModule Module + + // readyCallbacks stores callbacks waiting for a service to be registered + readyCallbacks map[string][]func(any) } // NewEnhancedServiceRegistry creates a new enhanced service registry. @@ -54,23 +60,30 @@ func NewEnhancedServiceRegistry() *EnhancedServiceRegistry { services: make(map[string]*ServiceRegistryEntry), moduleServices: make(map[string][]string), nameCounters: make(map[string]int), + readyCallbacks: make(map[string][]func(any)), } } // SetCurrentModule sets the module that is currently being initialized. // This is used to track which module is registering services. func (r *EnhancedServiceRegistry) SetCurrentModule(module Module) { + r.mu.Lock() + defer r.mu.Unlock() r.currentModule = module } // ClearCurrentModule clears the current module context. func (r *EnhancedServiceRegistry) ClearCurrentModule() { + r.mu.Lock() + defer r.mu.Unlock() r.currentModule = nil } // RegisterService registers a service with automatic conflict resolution. // If a service name conflicts, it will automatically append module information. func (r *EnhancedServiceRegistry) RegisterService(name string, service any) (string, error) { + r.mu.Lock() + var moduleName string var moduleType reflect.Type @@ -94,16 +107,34 @@ func (r *EnhancedServiceRegistry) RegisterService(name string, service any) (str // Register the service r.services[actualName] = entry + // Collect callbacks to fire outside the lock + var callbacksToFire []func(any) + for _, cbName := range []string{name, actualName} { + if callbacks, ok := r.readyCallbacks[cbName]; ok { + callbacksToFire = append(callbacksToFire, callbacks...) + delete(r.readyCallbacks, cbName) + } + } + // Track module associations if moduleName != "" { r.moduleServices[moduleName] = append(r.moduleServices[moduleName], actualName) } + r.mu.Unlock() + + // Fire callbacks outside the lock to avoid deadlocks + for _, cb := range callbacksToFire { + cb(service) + } + return actualName, nil } // GetService retrieves a service by name. func (r *EnhancedServiceRegistry) GetService(name string) (any, bool) { + r.mu.RLock() + defer r.mu.RUnlock() entry, exists := r.services[name] if !exists { return nil, false @@ -113,17 +144,37 @@ func (r *EnhancedServiceRegistry) GetService(name string) (any, bool) { // GetServiceEntry retrieves the full service registry entry. func (r *EnhancedServiceRegistry) GetServiceEntry(name string) (*ServiceRegistryEntry, bool) { + r.mu.RLock() + defer r.mu.RUnlock() entry, exists := r.services[name] return entry, exists } // GetServicesByModule returns all services provided by a specific module. func (r *EnhancedServiceRegistry) GetServicesByModule(moduleName string) []string { + r.mu.RLock() + defer r.mu.RUnlock() return r.moduleServices[moduleName] } +// OnServiceReady registers a callback that fires when the named service is registered. +// If the service is already registered, the callback fires immediately. +func (r *EnhancedServiceRegistry) OnServiceReady(name string, callback func(any)) { + r.mu.Lock() + entry, exists := r.services[name] + if exists { + r.mu.Unlock() + callback(entry.Service) + return + } + r.readyCallbacks[name] = append(r.readyCallbacks[name], callback) + r.mu.Unlock() +} + // GetServicesByInterface returns all services that implement the given interface. func (r *EnhancedServiceRegistry) GetServicesByInterface(interfaceType reflect.Type) []*ServiceRegistryEntry { + r.mu.RLock() + defer r.mu.RUnlock() var results []*ServiceRegistryEntry for _, entry := range r.services { @@ -141,6 +192,8 @@ func (r *EnhancedServiceRegistry) GetServicesByInterface(interfaceType reflect.T // AsServiceRegistry returns a backwards-compatible ServiceRegistry view. func (r *EnhancedServiceRegistry) AsServiceRegistry() ServiceRegistry { + r.mu.RLock() + defer r.mu.RUnlock() registry := make(ServiceRegistry) for name, entry := range r.services { registry[name] = entry.Service diff --git a/service_readiness_test.go b/service_readiness_test.go new file mode 100644 index 00000000..c630fdf6 --- /dev/null +++ b/service_readiness_test.go @@ -0,0 +1,52 @@ +package modular + +import ( + "sync/atomic" + "testing" +) + +func TestOnServiceReady_AlreadyRegistered(t *testing.T) { + registry := NewEnhancedServiceRegistry() + registry.RegisterService("db", "postgres-conn") + var called atomic.Bool + registry.OnServiceReady("db", func(svc any) { + called.Store(true) + if svc != "postgres-conn" { + t.Errorf("expected postgres-conn, got %v", svc) + } + }) + if !called.Load() { + t.Error("callback should have been called immediately") + } +} + +func TestOnServiceReady_DeferredUntilRegistration(t *testing.T) { + registry := NewEnhancedServiceRegistry() + var called atomic.Bool + var receivedSvc any + registry.OnServiceReady("db", func(svc any) { + called.Store(true) + receivedSvc = svc + }) + if called.Load() { + t.Error("callback should not have been called yet") + } + registry.RegisterService("db", "postgres-conn") + if !called.Load() { + t.Error("callback should have been called after registration") + } + if receivedSvc != "postgres-conn" { + t.Errorf("expected postgres-conn, got %v", receivedSvc) + } +} + +func TestOnServiceReady_MultipleCallbacks(t *testing.T) { + registry := NewEnhancedServiceRegistry() + var count atomic.Int32 + registry.OnServiceReady("cache", func(svc any) { count.Add(1) }) + registry.OnServiceReady("cache", func(svc any) { count.Add(1) }) + registry.RegisterService("cache", "redis") + if count.Load() != 2 { + t.Errorf("expected 2 callbacks, got %d", count.Load()) + } +} diff --git a/service_registration_timing_test.go b/service_registration_timing_test.go index d4e903fb..13ac632e 100644 --- a/service_registration_timing_test.go +++ b/service_registration_timing_test.go @@ -124,7 +124,7 @@ type serviceConsumerModule struct { requiredService string dependencies []string serviceReceived bool - receivedValue interface{} + receivedValue any } func (m *serviceConsumerModule) Name() string { @@ -137,7 +137,7 @@ func (m *serviceConsumerModule) Dependencies() []string { func (m *serviceConsumerModule) Init(app Application) error { // Try to get the required service during Init - var service interface{} + var service any err := app.GetService(m.requiredService, &service) if err != nil { return err @@ -270,7 +270,7 @@ type serviceConsumerWithRequires struct { requiredServices []ServiceDependency dependencies []string servicesInjected bool - injectedService interface{} + injectedService any } func (m *serviceConsumerWithRequires) Name() string { @@ -315,7 +315,7 @@ type serviceConsumerWithDeclaredRequires struct { requiredServices []ServiceDependency requiredService string serviceReceived bool - receivedValue interface{} + receivedValue any } func (m *serviceConsumerWithDeclaredRequires) Name() string { @@ -324,7 +324,7 @@ func (m *serviceConsumerWithDeclaredRequires) Name() string { func (m *serviceConsumerWithDeclaredRequires) Init(app Application) error { // Try to get the required service during Init - var service interface{} + var service any err := app.GetService(m.requiredService, &service) if err != nil { return err diff --git a/service_registry_scenarios_bdd_test.go b/service_registry_scenarios_bdd_test.go index b9f0f30e..13caa348 100644 --- a/service_registry_scenarios_bdd_test.go +++ b/service_registry_scenarios_bdd_test.go @@ -83,7 +83,7 @@ func (ctx *EnhancedServiceRegistryBDDContext) iQueryForServicesByInterfaceType() } // Query for services implementing TestServiceInterface - interfaceType := reflect.TypeOf((*TestServiceInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[TestServiceInterface]() ctx.retrievedServices = ctx.app.GetServicesByInterface(interfaceType) return nil } @@ -173,7 +173,7 @@ func (ctx *EnhancedServiceRegistryBDDContext) eachServiceShouldGetAUniqueNameThr } func (ctx *EnhancedServiceRegistryBDDContext) allServicesShouldBeDiscoverableByInterface() error { - interfaceType := reflect.TypeOf((*TestServiceInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[TestServiceInterface]() services := ctx.app.GetServicesByInterface(interfaceType) if len(services) != 3 { diff --git a/service_typed.go b/service_typed.go new file mode 100644 index 00000000..bebc5504 --- /dev/null +++ b/service_typed.go @@ -0,0 +1,23 @@ +package modular + +import "fmt" + +// RegisterTypedService registers a service with compile-time type safety. +func RegisterTypedService[T any](app Application, name string, svc T) error { + return app.RegisterService(name, svc) +} + +// GetTypedService retrieves a service with compile-time type safety. +func GetTypedService[T any](app Application, name string) (T, error) { + var zero T + svcRegistry := app.SvcRegistry() + raw, exists := svcRegistry[name] + if !exists { + return zero, fmt.Errorf("%w: %s", ErrServiceNotFound, name) + } + typed, ok := raw.(T) + if !ok { + return zero, fmt.Errorf("%w: service %q is %T, want %T", ErrServiceWrongType, name, raw, zero) + } + return typed, nil +} diff --git a/service_typed_test.go b/service_typed_test.go new file mode 100644 index 00000000..57f55871 --- /dev/null +++ b/service_typed_test.go @@ -0,0 +1,37 @@ +package modular + +import "testing" + +type testTypedService struct{ Value string } + +func TestRegisterTypedService_and_GetTypedService(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + svc := &testTypedService{Value: "hello"} + if err := RegisterTypedService(app, "test.svc", svc); err != nil { + t.Fatalf("RegisterTypedService: %v", err) + } + got, err := GetTypedService[*testTypedService](app, "test.svc") + if err != nil { + t.Fatalf("GetTypedService: %v", err) + } + if got.Value != "hello" { + t.Errorf("expected hello, got %s", got.Value) + } +} + +func TestGetTypedService_WrongType(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + _ = RegisterTypedService(app, "str.svc", "hello") + _, err := GetTypedService[int](app, "str.svc") + if err == nil { + t.Fatal("expected type mismatch error") + } +} + +func TestGetTypedService_NotFound(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + _, err := GetTypedService[string](app, "missing") + if err == nil { + t.Fatal("expected not found error") + } +} diff --git a/slog_adapter.go b/slog_adapter.go new file mode 100644 index 00000000..d703583e --- /dev/null +++ b/slog_adapter.go @@ -0,0 +1,28 @@ +package modular + +import "log/slog" + +// SlogAdapter wraps a *slog.Logger to implement the Logger interface. +type SlogAdapter struct { + logger *slog.Logger +} + +// NewSlogAdapter creates a new SlogAdapter wrapping the given slog.Logger. +func NewSlogAdapter(l *slog.Logger) *SlogAdapter { + return &SlogAdapter{logger: l} +} + +func (a *SlogAdapter) Info(msg string, args ...any) { a.logger.Info(msg, args...) } +func (a *SlogAdapter) Error(msg string, args ...any) { a.logger.Error(msg, args...) } +func (a *SlogAdapter) Warn(msg string, args ...any) { a.logger.Warn(msg, args...) } +func (a *SlogAdapter) Debug(msg string, args ...any) { a.logger.Debug(msg, args...) } + +// With returns a new SlogAdapter with the given key-value pairs added to the context. +func (a *SlogAdapter) With(args ...any) *SlogAdapter { + return &SlogAdapter{logger: a.logger.With(args...)} +} + +// WithGroup returns a new SlogAdapter with the given group name. +func (a *SlogAdapter) WithGroup(name string) *SlogAdapter { + return &SlogAdapter{logger: a.logger.WithGroup(name)} +} diff --git a/slog_adapter_test.go b/slog_adapter_test.go new file mode 100644 index 00000000..a3b774fa --- /dev/null +++ b/slog_adapter_test.go @@ -0,0 +1,51 @@ +package modular + +import ( + "bytes" + "log/slog" + "strings" + "testing" +) + +func TestSlogAdapter_ImplementsLogger(t *testing.T) { + var _ Logger = (*SlogAdapter)(nil) +} + +func TestSlogAdapter_DelegatesToSlog(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := slog.New(handler) + adapter := NewSlogAdapter(logger) + + adapter.Info("test info", "key", "value") + adapter.Error("test error", "err", "fail") + adapter.Warn("test warn") + adapter.Debug("test debug") + + output := buf.String() + for _, msg := range []string{"test info", "test error", "test warn", "test debug"} { + if !strings.Contains(output, msg) { + t.Errorf("expected %q in output", msg) + } + } +} + +func TestSlogAdapter_With(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + adapter := NewSlogAdapter(slog.New(handler)).With("module", "test") + adapter.Info("with test") + if !strings.Contains(buf.String(), "module=test") { + t.Errorf("expected module=test in output, got: %s", buf.String()) + } +} + +func TestSlogAdapter_WithGroup(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + adapter := NewSlogAdapter(slog.New(handler)).WithGroup("mygroup") + adapter.Info("group test", "key", "val") + if !strings.Contains(buf.String(), "mygroup") { + t.Errorf("expected mygroup in output, got: %s", buf.String()) + } +} diff --git a/tenant_config_affixed_env_bug_test.go b/tenant_config_affixed_env_bug_test.go index 39dd7388..531d29d1 100644 --- a/tenant_config_affixed_env_bug_test.go +++ b/tenant_config_affixed_env_bug_test.go @@ -7,7 +7,7 @@ import ( "regexp" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // TestTenantConfigAffixedEnvBug tests the specific bug where tenant config loading diff --git a/tenant_config_file_loader.go b/tenant_config_file_loader.go index 71310d06..0601a16e 100644 --- a/tenant_config_file_loader.go +++ b/tenant_config_file_loader.go @@ -9,7 +9,7 @@ import ( "regexp" "strings" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // Static errors for better error handling @@ -482,13 +482,13 @@ func getSectionNames(sections map[string]ConfigProvider) []string { // cloneConfigWithValues creates a new instance of the originalConfig type // and copies values from loadedConfig into it -func cloneConfigWithValues(originalConfig, loadedConfig interface{}) (interface{}, error) { +func cloneConfigWithValues(originalConfig, loadedConfig any) (any, error) { if originalConfig == nil || loadedConfig == nil { return nil, ErrOriginalOrLoadedNil } origType := reflect.TypeOf(originalConfig) - if origType.Kind() == reflect.Ptr { + if origType.Kind() == reflect.Pointer { origType = origType.Elem() } @@ -504,21 +504,21 @@ func cloneConfigWithValues(originalConfig, loadedConfig interface{}) (interface{ } // copyStructFields copies field values from src to dst -func copyStructFields(dst, src interface{}) error { +func copyStructFields(dst, src any) error { dstVal := reflect.ValueOf(dst) srcVal := reflect.ValueOf(src) // Ensure we're working with pointers - if dstVal.Kind() != reflect.Ptr { + if dstVal.Kind() != reflect.Pointer { return ErrDestinationNotPointer } // Dereference pointers to get the underlying values - if dstVal.Kind() == reflect.Ptr { + if dstVal.Kind() == reflect.Pointer { dstVal = dstVal.Elem() } - if srcVal.Kind() == reflect.Ptr { + if srcVal.Kind() == reflect.Pointer { srcVal = srcVal.Elem() } diff --git a/tenant_config_loader_test.go b/tenant_config_loader_test.go index f9462133..e4ac8ace 100644 --- a/tenant_config_loader_test.go +++ b/tenant_config_loader_test.go @@ -1,6 +1,7 @@ package modular import ( + "maps" "log/slog" "os" "path/filepath" @@ -43,9 +44,7 @@ func (m *MockTenantService) RegisterTenant(tenantID TenantID, configs map[string m.tenants[tenantID] = make(map[string]ConfigProvider) } - for section, provider := range configs { - m.tenants[tenantID][section] = provider - } + maps.Copy(m.tenants[tenantID], configs) return nil } diff --git a/tenant_config_provider.go b/tenant_config_provider.go index cb552047..4a905196 100644 --- a/tenant_config_provider.go +++ b/tenant_config_provider.go @@ -65,7 +65,7 @@ func (tcp *TenantConfigProvider) SetTenantConfig(tenantID TenantID, section stri // Ensure the config is a valid, non-zero value cfgValue := reflect.ValueOf(cfg) - if cfgValue.Kind() == reflect.Ptr && cfgValue.IsNil() { + if cfgValue.Kind() == reflect.Pointer && cfgValue.IsNil() { return } diff --git a/tenant_config_test.go b/tenant_config_test.go index f549e545..60b37c03 100644 --- a/tenant_config_test.go +++ b/tenant_config_test.go @@ -248,7 +248,7 @@ func TestLoadTenantConfigsNonexistentDirectory(t *testing.T) { ConfigDir: nonExistentDir, } - log.On("Error", "Tenant config directory does not exist", []interface{}{"directory", nonExistentDir}).Return(nil) + log.On("Error", "Tenant config directory does not exist", []any{"directory", nonExistentDir}).Return(nil) err := LoadTenantConfigs(app, tenantService, params) if err == nil || !strings.Contains(err.Error(), "tenant config directory does not exist") { t.Errorf("Expected error for nonexistent directory, got: %v", err) @@ -323,7 +323,7 @@ func TestTenantConfigProviderSetAndGet(t *testing.T) { } // Test nil config - nilProviderStruct := &struct{ Config interface{} }{nil} + nilProviderStruct := &struct{ Config any }{nil} nilProvider := NewStdConfigProvider(nilProviderStruct.Config) tcp.SetTenantConfig(tenant1ID, "NilConfigSection", nilProvider) if tcp.HasTenantConfig(tenant1ID, "NilConfigSection") { @@ -403,7 +403,7 @@ func TestCopyStructFields(t *testing.T) { } // Test copying map to struct - srcMap := map[string]interface{}{ + srcMap := map[string]any{ "Name": "MapSource", "Environment": "prod", "Features": map[string]bool{"feature2": true}, diff --git a/tenant_guard.go b/tenant_guard.go new file mode 100644 index 00000000..625ab801 --- /dev/null +++ b/tenant_guard.go @@ -0,0 +1,294 @@ +package modular + +import ( + "context" + "fmt" + "sync" + "time" +) + +// TenantGuardMode controls how the tenant guard responds to violations. +type TenantGuardMode int + +const ( + // TenantGuardStrict blocks the operation and returns an error on violation. + TenantGuardStrict TenantGuardMode = iota + // TenantGuardLenient records the violation and allows the operation to proceed. + // Violations are logged when LogViolations is true and a logger is configured. + TenantGuardLenient + // TenantGuardDisabled performs no validation at all. + TenantGuardDisabled +) + +// String returns the string representation of a TenantGuardMode. +func (m TenantGuardMode) String() string { + switch m { + case TenantGuardStrict: + return "strict" + case TenantGuardLenient: + return "lenient" + case TenantGuardDisabled: + return "disabled" + default: + return fmt.Sprintf("unknown(%d)", int(m)) + } +} + +// ViolationType categorizes the kind of tenant boundary violation. +type ViolationType int + +const ( + // CrossTenant indicates an attempt to access another tenant's resources. + CrossTenant ViolationType = iota + // InvalidContext indicates the tenant context is malformed or invalid. + InvalidContext + // MissingContext indicates no tenant context was provided. + MissingContext + // Unauthorized indicates the caller lacks permission for the tenant operation. + Unauthorized +) + +// String returns the string representation of a ViolationType. +func (v ViolationType) String() string { + switch v { + case CrossTenant: + return "cross_tenant" + case InvalidContext: + return "invalid_context" + case MissingContext: + return "missing_context" + case Unauthorized: + return "unauthorized" + default: + return fmt.Sprintf("unknown(%d)", int(v)) + } +} + +// Severity indicates the severity level of a tenant violation. +type Severity int + +const ( + // SeverityLow indicates a minor violation. + SeverityLow Severity = iota + // SeverityMedium indicates a moderate violation. + SeverityMedium + // SeverityHigh indicates a serious violation. + SeverityHigh + // SeverityCritical indicates a critical violation requiring immediate attention. + SeverityCritical +) + +// String returns the string representation of a Severity. +func (s Severity) String() string { + switch s { + case SeverityLow: + return "low" + case SeverityMedium: + return "medium" + case SeverityHigh: + return "high" + case SeverityCritical: + return "critical" + default: + return fmt.Sprintf("unknown(%d)", int(s)) + } +} + +// TenantViolation represents a detected tenant boundary violation. +type TenantViolation struct { + Type ViolationType + Severity Severity + TenantID string + TargetID string + Timestamp time.Time + Details string +} + +// TenantGuard validates tenant access and tracks violations. +type TenantGuard interface { + // GetMode returns the current guard mode. + GetMode() TenantGuardMode + + // ValidateAccess checks whether the given violation should be blocked. + // In Strict mode, it returns an error. In Lenient mode, it records the + // violation but returns nil. In Disabled mode, it is a no-op. + ValidateAccess(ctx context.Context, violation TenantViolation) error + + // GetRecentViolations returns a deep copy of recent violations, ordered oldest-first. + GetRecentViolations() []TenantViolation +} + +// TenantGuardConfig holds configuration for a StandardTenantGuard. +type TenantGuardConfig struct { + Mode TenantGuardMode + Whitelist map[string][]string // tenantID -> allowed target IDs + MaxViolations int // ring buffer capacity, default 1000 + LogViolations bool // whether to log violations, default true +} + +// DefaultTenantGuardConfig returns a TenantGuardConfig with sensible defaults. +func DefaultTenantGuardConfig() TenantGuardConfig { + return TenantGuardConfig{ + Mode: TenantGuardStrict, + Whitelist: make(map[string][]string), + MaxViolations: 1000, + LogViolations: true, + } +} + +// TenantGuardOption is a functional option for configuring a StandardTenantGuard. +type TenantGuardOption func(*StandardTenantGuard) + +// WithTenantGuardLogger sets a structured logger on the guard. +func WithTenantGuardLogger(l Logger) TenantGuardOption { + return func(g *StandardTenantGuard) { + g.logger = l + } +} + +// WithTenantGuardSubject sets a Subject for event emission on the guard. +func WithTenantGuardSubject(s Subject) TenantGuardOption { + return func(g *StandardTenantGuard) { + g.subject = s + } +} + +// StandardTenantGuard is the default TenantGuard implementation. +// It uses a ring buffer to store recent violations and optionally emits +// CloudEvents when violations are detected. +type StandardTenantGuard struct { + config TenantGuardConfig + whitelist map[string]map[string]struct{} // deep-copied set for fast lookups + violations []TenantViolation + head int + count int + mu sync.RWMutex + logger Logger + subject Subject +} + +// NewStandardTenantGuard creates a new StandardTenantGuard with the given config and options. +// The whitelist is deep-copied and converted to a set for safe, fast lookups. +func NewStandardTenantGuard(config TenantGuardConfig, opts ...TenantGuardOption) *StandardTenantGuard { + if config.MaxViolations <= 0 { + config.MaxViolations = 1000 + } + + // Deep-copy and convert whitelist to set + wl := make(map[string]map[string]struct{}, len(config.Whitelist)) + for tenant, targets := range config.Whitelist { + set := make(map[string]struct{}, len(targets)) + for _, t := range targets { + set[t] = struct{}{} + } + wl[tenant] = set + } + + g := &StandardTenantGuard{ + config: config, + whitelist: wl, + violations: make([]TenantViolation, config.MaxViolations), + } + + for _, opt := range opts { + opt(g) + } + + return g +} + +// GetMode returns the current guard mode. +func (g *StandardTenantGuard) GetMode() TenantGuardMode { + return g.config.Mode +} + +// ValidateAccess checks the violation against the guard's policy. +func (g *StandardTenantGuard) ValidateAccess(ctx context.Context, violation TenantViolation) error { + if g.config.Mode == TenantGuardDisabled { + return nil + } + + // Set timestamp if not provided + if violation.Timestamp.IsZero() { + violation.Timestamp = time.Now() + } + + // Check whitelist (set-based O(1) lookup) + if targets, ok := g.whitelist[violation.TenantID]; ok { + if _, allowed := targets[violation.TargetID]; allowed { + return nil + } + } + + // Record violation + g.mu.Lock() + g.addViolation(violation) + g.mu.Unlock() + + // Log if configured + if g.config.LogViolations && g.logger != nil { + g.logger.Warn("Tenant violation detected", + "type", violation.Type.String(), + "severity", violation.Severity.String(), + "tenant", violation.TenantID, + "target", violation.TargetID, + "details", violation.Details, + ) + } + + // Emit event using NewCloudEvent helper (sets ID, specversion, time) + if g.subject != nil { + event := NewCloudEvent(EventTypeTenantViolation, "com.modular.tenant.guard", violation, nil) + if err := g.subject.NotifyObservers(ctx, event); err != nil && g.logger != nil { + g.logger.Warn("Failed to emit tenant violation event", + "error", err, + "tenant", violation.TenantID, + "type", violation.Type.String(), + ) + } + } + + // In strict mode, return error + if g.config.Mode == TenantGuardStrict { + return ErrTenantIsolationViolation + } + + // Lenient mode: violation recorded, but allow the operation + return nil +} + +// GetRecentViolations returns a deep copy of recent violations ordered oldest-first. +func (g *StandardTenantGuard) GetRecentViolations() []TenantViolation { + g.mu.RLock() + defer g.mu.RUnlock() + + if g.count == 0 { + return nil + } + + result := make([]TenantViolation, g.count) + max := g.config.MaxViolations + + if g.count < max { + // Buffer not yet full — entries are at indices 0..count-1 + copy(result, g.violations[:g.count]) + } else { + // Buffer full — oldest is at head, wrap around + oldest := g.head % max + n := copy(result, g.violations[oldest:]) + copy(result[n:], g.violations[:oldest]) + } + + return result +} + +// addViolation writes a violation into the ring buffer. +// Caller must hold the write lock. +func (g *StandardTenantGuard) addViolation(v TenantViolation) { + max := g.config.MaxViolations + g.violations[g.head%max] = v + g.head++ + if g.count < max { + g.count++ + } +} diff --git a/tenant_guard_test.go b/tenant_guard_test.go new file mode 100644 index 00000000..21316c3b --- /dev/null +++ b/tenant_guard_test.go @@ -0,0 +1,335 @@ +package modular + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +// tenantGuardTestLogger is a Logger implementation that counts Warn calls for testing. +type tenantGuardTestLogger struct { + warnCalls atomic.Int32 +} + +func (l *tenantGuardTestLogger) Info(_ string, _ ...any) {} +func (l *tenantGuardTestLogger) Error(_ string, _ ...any) {} +func (l *tenantGuardTestLogger) Debug(_ string, _ ...any) {} +func (l *tenantGuardTestLogger) Warn(_ string, _ ...any) { + l.warnCalls.Add(1) +} + +func TestTenantGuardMode_String(t *testing.T) { + tests := []struct { + mode TenantGuardMode + want string + }{ + {TenantGuardStrict, "strict"}, + {TenantGuardLenient, "lenient"}, + {TenantGuardDisabled, "disabled"}, + {TenantGuardMode(99), "unknown(99)"}, + } + + for _, tt := range tests { + got := tt.mode.String() + if got != tt.want { + t.Errorf("TenantGuardMode(%d).String() = %q, want %q", int(tt.mode), got, tt.want) + } + } +} + +func TestViolationType_String(t *testing.T) { + tests := []struct { + vt ViolationType + want string + }{ + {CrossTenant, "cross_tenant"}, + {InvalidContext, "invalid_context"}, + {MissingContext, "missing_context"}, + {Unauthorized, "unauthorized"}, + {ViolationType(99), "unknown(99)"}, + } + + for _, tt := range tests { + got := tt.vt.String() + if got != tt.want { + t.Errorf("ViolationType(%d).String() = %q, want %q", int(tt.vt), got, tt.want) + } + } +} + +func TestSeverity_String(t *testing.T) { + tests := []struct { + sev Severity + want string + }{ + {SeverityLow, "low"}, + {SeverityMedium, "medium"}, + {SeverityHigh, "high"}, + {SeverityCritical, "critical"}, + {Severity(99), "unknown(99)"}, + } + + for _, tt := range tests { + got := tt.sev.String() + if got != tt.want { + t.Errorf("Severity(%d).String() = %q, want %q", int(tt.sev), got, tt.want) + } + } +} + +func TestStandardTenantGuard_StrictMode(t *testing.T) { + config := DefaultTenantGuardConfig() + config.Mode = TenantGuardStrict + guard := NewStandardTenantGuard(config) + + err := guard.ValidateAccess(context.Background(), TenantViolation{ + Type: CrossTenant, + Severity: SeverityHigh, + TenantID: "tenant-1", + TargetID: "tenant-2", + Details: "cross-tenant data access", + }) + + if err == nil { + t.Fatal("expected error in strict mode, got nil") + } + if !errors.Is(err, ErrTenantIsolationViolation) { + t.Errorf("expected ErrTenantIsolationViolation, got %v", err) + } + + violations := guard.GetRecentViolations() + if len(violations) != 1 { + t.Fatalf("expected 1 violation recorded, got %d", len(violations)) + } + if violations[0].TenantID != "tenant-1" { + t.Errorf("expected tenant-1, got %s", violations[0].TenantID) + } +} + +func TestStandardTenantGuard_LenientMode(t *testing.T) { + config := DefaultTenantGuardConfig() + config.Mode = TenantGuardLenient + + logger := &tenantGuardTestLogger{} + guard := NewStandardTenantGuard(config, WithTenantGuardLogger(logger)) + + err := guard.ValidateAccess(context.Background(), TenantViolation{ + Type: CrossTenant, + Severity: SeverityMedium, + TenantID: "tenant-1", + TargetID: "tenant-2", + Details: "lenient test", + }) + + if err != nil { + t.Fatalf("expected nil error in lenient mode, got %v", err) + } + + violations := guard.GetRecentViolations() + if len(violations) != 1 { + t.Fatalf("expected 1 violation recorded, got %d", len(violations)) + } + + if logger.warnCalls.Load() == 0 { + t.Error("expected log output for violation, got none") + } +} + +func TestStandardTenantGuard_DisabledMode(t *testing.T) { + config := DefaultTenantGuardConfig() + config.Mode = TenantGuardDisabled + guard := NewStandardTenantGuard(config) + + err := guard.ValidateAccess(context.Background(), TenantViolation{ + Type: CrossTenant, + Severity: SeverityCritical, + TenantID: "tenant-1", + TargetID: "tenant-2", + }) + + if err != nil { + t.Fatalf("expected nil error in disabled mode, got %v", err) + } + + violations := guard.GetRecentViolations() + if len(violations) != 0 { + t.Errorf("expected 0 violations in disabled mode, got %d", len(violations)) + } +} + +func TestStandardTenantGuard_Whitelist(t *testing.T) { + config := DefaultTenantGuardConfig() + config.Mode = TenantGuardStrict + config.Whitelist = map[string][]string{ + "tenant-1": {"tenant-2", "tenant-3"}, + } + guard := NewStandardTenantGuard(config) + + // Whitelisted access should succeed + err := guard.ValidateAccess(context.Background(), TenantViolation{ + Type: CrossTenant, + Severity: SeverityHigh, + TenantID: "tenant-1", + TargetID: "tenant-2", + }) + if err != nil { + t.Fatalf("expected nil for whitelisted access, got %v", err) + } + + // Non-whitelisted access should fail in strict mode + err = guard.ValidateAccess(context.Background(), TenantViolation{ + Type: CrossTenant, + Severity: SeverityHigh, + TenantID: "tenant-1", + TargetID: "tenant-99", + }) + if !errors.Is(err, ErrTenantIsolationViolation) { + t.Errorf("expected ErrTenantIsolationViolation for non-whitelisted access, got %v", err) + } + + // Only the non-whitelisted violation should be recorded + violations := guard.GetRecentViolations() + if len(violations) != 1 { + t.Fatalf("expected 1 violation, got %d", len(violations)) + } +} + +func TestStandardTenantGuard_RingBuffer(t *testing.T) { + config := DefaultTenantGuardConfig() + config.Mode = TenantGuardLenient + config.MaxViolations = 5 + config.LogViolations = false + guard := NewStandardTenantGuard(config) + + // Add 8 violations to a buffer of size 5 + for i := range 8 { + _ = guard.ValidateAccess(context.Background(), TenantViolation{ + Type: CrossTenant, + Severity: SeverityLow, + TenantID: "tenant-1", + TargetID: "target-" + string(rune('A'+i)), + Details: "violation", + }) + } + + violations := guard.GetRecentViolations() + if len(violations) != 5 { + t.Fatalf("expected 5 violations (buffer size), got %d", len(violations)) + } + + // Oldest should be violation index 3 (target-D), newest should be index 7 (target-H) + expectedTargets := []string{"target-D", "target-E", "target-F", "target-G", "target-H"} + for i, v := range violations { + if v.TargetID != expectedTargets[i] { + t.Errorf("violation[%d].TargetID = %q, want %q", i, v.TargetID, expectedTargets[i]) + } + } +} + +func TestStandardTenantGuard_GetRecentViolations_DeepCopy(t *testing.T) { + config := DefaultTenantGuardConfig() + config.Mode = TenantGuardLenient + config.LogViolations = false + guard := NewStandardTenantGuard(config) + + _ = guard.ValidateAccess(context.Background(), TenantViolation{ + Type: CrossTenant, + Severity: SeverityHigh, + TenantID: "tenant-1", + TargetID: "tenant-2", + Details: "original", + }) + + // Get a copy and modify it + copy1 := guard.GetRecentViolations() + copy1[0].Details = "modified" + + // Get another copy — it should still have the original value + copy2 := guard.GetRecentViolations() + if copy2[0].Details != "original" { + t.Errorf("internal state was mutated: expected 'original', got %q", copy2[0].Details) + } +} + +func TestStandardTenantGuard_ConcurrentAccess(t *testing.T) { + config := DefaultTenantGuardConfig() + config.Mode = TenantGuardLenient + config.MaxViolations = 100 + config.LogViolations = false + guard := NewStandardTenantGuard(config) + + var wg sync.WaitGroup + for i := range 100 { + wg.Add(1) + go func(idx int) { + defer wg.Done() + _ = guard.ValidateAccess(context.Background(), TenantViolation{ + Type: CrossTenant, + Severity: SeverityLow, + TenantID: "tenant-1", + TargetID: "tenant-2", + }) + }(i) + } + wg.Wait() + + violations := guard.GetRecentViolations() + if len(violations) != 100 { + t.Errorf("expected 100 violations from concurrent access, got %d", len(violations)) + } +} + +func TestStandardTenantGuard_TimestampAutoSet(t *testing.T) { + config := DefaultTenantGuardConfig() + config.Mode = TenantGuardLenient + config.LogViolations = false + guard := NewStandardTenantGuard(config) + + before := time.Now() + _ = guard.ValidateAccess(context.Background(), TenantViolation{ + Type: MissingContext, + Severity: SeverityMedium, + TenantID: "tenant-1", + }) + after := time.Now() + + violations := guard.GetRecentViolations() + if len(violations) != 1 { + t.Fatalf("expected 1 violation, got %d", len(violations)) + } + + ts := violations[0].Timestamp + if ts.Before(before) || ts.After(after) { + t.Errorf("timestamp %v not between %v and %v", ts, before, after) + } +} + +func TestStandardTenantGuard_GetMode(t *testing.T) { + for _, mode := range []TenantGuardMode{TenantGuardStrict, TenantGuardLenient, TenantGuardDisabled} { + config := DefaultTenantGuardConfig() + config.Mode = mode + guard := NewStandardTenantGuard(config) + if guard.GetMode() != mode { + t.Errorf("GetMode() = %v, want %v", guard.GetMode(), mode) + } + } +} + +func TestStandardTenantGuard_DefaultMaxViolations(t *testing.T) { + config := DefaultTenantGuardConfig() + if config.MaxViolations != 1000 { + t.Errorf("DefaultTenantGuardConfig().MaxViolations = %d, want 1000", config.MaxViolations) + } + if !config.LogViolations { + t.Error("DefaultTenantGuardConfig().LogViolations should be true") + } + if config.Mode != TenantGuardStrict { + t.Errorf("DefaultTenantGuardConfig().Mode = %v, want strict", config.Mode) + } +} + +// Verify StandardTenantGuard satisfies the TenantGuard interface +var _ TenantGuard = (*StandardTenantGuard)(nil) diff --git a/tenant_service.go b/tenant_service.go index 80953420..d432cec4 100644 --- a/tenant_service.go +++ b/tenant_service.go @@ -3,6 +3,7 @@ package modular import ( + "slices" "fmt" "sync" ) @@ -165,13 +166,11 @@ func (ts *StandardTenantService) RegisterTenantAwareModule(module TenantAwareMod defer ts.mutex.Unlock() // Check if the module is already registered to avoid duplicates - for _, existingModule := range ts.tenantAwareModules { - if existingModule == module { + if slices.Contains(ts.tenantAwareModules, module) { ts.logger.Debug("Module already registered as tenant-aware", "module", fmt.Sprintf("%T", module), "name", module.Name()) return nil } - } ts.tenantAwareModules = append(ts.tenantAwareModules, module) ts.logger.Debug("Registered tenant-aware module", diff --git a/user_scenario_integration_test.go b/user_scenario_integration_test.go index f596126e..4df7c9f2 100644 --- a/user_scenario_integration_test.go +++ b/user_scenario_integration_test.go @@ -34,7 +34,7 @@ func TestUserScenarioReproduction(t *testing.T) { t.Log("Service entry not found (expected for nil service)") } - interfaceType := reflect.TypeOf((*TestUserInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[TestUserInterface]() interfaceServices := app.GetServicesByInterface(interfaceType) t.Logf("Services implementing interface: %d", len(interfaceServices)) @@ -57,7 +57,7 @@ func TestBackwardsCompatibilityCheck(t *testing.T) { t.Errorf("Expected no entry for nonexistent service, got %v, %v", entry, found) } - interfaceType := reflect.TypeOf((*TestUserInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[TestUserInterface]() interfaceServices := app.GetServicesByInterface(interfaceType) if len(interfaceServices) != 0 { t.Errorf("Expected no interface services, got %v", interfaceServices) @@ -92,7 +92,7 @@ func (m *testInterfaceConsumerModule) RequiresServices() []ServiceDependency { return []ServiceDependency{{ Name: "testInterface", MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestUserInterface)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestUserInterface](), Required: false, // Optional to avoid initialization failures }} } diff --git a/user_scenario_test.go b/user_scenario_test.go index 5a7755ea..891c3e48 100644 --- a/user_scenario_test.go +++ b/user_scenario_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require"