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_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/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/builder.go b/builder.go index b03b1e88..6567418e 100644 --- a/builder.go +++ b/builder.go @@ -20,7 +20,9 @@ type ApplicationBuilder struct { tenantLoader TenantLoader enableObserver bool enableTenant bool - configLoadedHooks []func(Application) error // Hooks to run after config loading + configLoadedHooks []func(Application) error // Hooks to run after config loading + tenantGuard *StandardTenantGuard + tenantGuardConfig *TenantGuardConfig } // ObserverFunc is a functional observer that can be registered with the application @@ -97,6 +99,11 @@ func (b *ApplicationBuilder) Build() (Application, error) { app = NewObservableDecorator(app, b.observers...) } + // Create tenant guard if configured + if b.tenantGuardConfig != nil { + b.tenantGuard = NewStandardTenantGuard(*b.tenantGuardConfig) + } + // Register modules for _, module := range b.modules { app.RegisterModule(module) @@ -194,6 +201,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/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/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..bb5bd529 100644 --- a/config_feeders.go +++ b/config_feeders.go @@ -1,7 +1,7 @@ package modular import ( - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // Feeder defines the interface for configuration feeders that provide configuration data. 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..a3ac579a 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" diff --git a/config_full_flow_field_tracking_test.go b/config_full_flow_field_tracking_test.go index 2a45a303..8da63f59 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" diff --git a/config_validation_test.go b/config_validation_test.go index b9ffce19..348335e2 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" ) 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/errors.go b/errors.go index 8693c401..d1d2630c 100644 --- a/errors.go +++ b/errors.go @@ -82,6 +82,10 @@ 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") + // Observer/Event emission errors ErrNoSubjectForEventEmission = errors.New("no subject available for event emission") 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/feeder_priority_test.go b/feeder_priority_test.go index 54417858..5dff6c8b 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 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..dae46b2b 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/CrisisTextLine/modular +module github.com/GoCodeAlone/modular go 1.25 diff --git a/health.go b/health.go new file mode 100644 index 00000000..7f323101 --- /dev/null +++ b/health.go @@ -0,0 +1,138 @@ +package modular + +import ( + "context" + "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 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, err + } + all = append(all, reports...) + } + return all, nil +} diff --git a/health_service.go b/health_service.go new file mode 100644 index 00000000..347ae48a --- /dev/null +++ b/health_service.go @@ -0,0 +1,272 @@ +package modular + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/google/uuid" +) + +// 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 *log.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 logger for the health service. +func WithHealthLogger(l *log.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. +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) { + cached := s.cache + s.cacheMu.RUnlock() + return cached, nil + } + s.cacheMu.RUnlock() + } + + // Snapshot providers under read lock + s.mu.RLock() + providers := make(map[string]HealthProvider, len(s.providers)) + for k, v := range s.providers { + providers[k] = v + } + 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) { + result := <-ch + + 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 aggregated, nil +} + +func (s *AggregateHealthService) emitHealthEvaluated(ctx context.Context, agg *AggregatedHealth) { + if s.subject == nil { + return + } + event := cloudevents.NewEvent() + event.SetID(uuid.New().String()) + event.SetType(EventTypeHealthEvaluated) + event.SetSource("modular/health-service") + event.SetTime(agg.GeneratedAt) + _ = event.SetData(cloudevents.ApplicationJSON, map[string]any{ + "readiness": agg.Readiness.String(), + "health": agg.Health.String(), + "report_count": len(agg.Reports), + }) + if err := s.subject.NotifyObservers(ctx, event); err != nil && s.logger != nil { + s.logger.Printf("failed to emit health evaluated event: %v", err) + } +} + +func (s *AggregateHealthService) emitHealthStatusChanged(ctx context.Context, from, to HealthStatus) { + if s.subject == nil { + return + } + event := cloudevents.NewEvent() + event.SetID(uuid.New().String()) + event.SetType(EventTypeHealthStatusChanged) + event.SetSource("modular/health-service") + event.SetTime(time.Now()) + _ = event.SetData(cloudevents.ApplicationJSON, map[string]any{ + "previous_status": from.String(), + "current_status": to.String(), + }) + if err := s.subject.NotifyObservers(ctx, event); err != nil && s.logger != nil { + s.logger.Printf("failed to emit health status changed event: %v", err) + } +} + +// worstStatus returns the worse of two health statuses. +// StatusUnknown is treated as StatusUnhealthy for aggregation purposes. +func worstStatus(a, b HealthStatus) HealthStatus { + ar := normalizeForAggregation(a) + br := normalizeForAggregation(b) + if ar > br { + return a + } + if br > ar { + return b + } + return a +} + +// 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..5dec4945 --- /dev/null +++ b/health_test.go @@ -0,0 +1,437 @@ +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.Add(1) + go func() { + defer wg.Done() + 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/instance_aware_comprehensive_regression_test.go b/instance_aware_comprehensive_regression_test.go index 5e09f5e2..113f3c42 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 diff --git a/instance_aware_feeding_test.go b/instance_aware_feeding_test.go index 35dd5d4a..8a028a0c 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. 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/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/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/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..b337dbdd 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:KxH4WgdEMSzSw9xY1yNwHbQ4/pGxRM9ml5psNujR6F4= +github.com/GoCodeAlone/modular/modules/httpserver v1.12.0/go.mod h1:CTV3eBq7st01TDw+sE0CjUhkr4vmG0e1j7j4EhxM6v8= 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/observer.go b/observer.go index 5077919b..71ac0583 100644 --- a/observer.go +++ b/observer.go @@ -90,6 +90,19 @@ 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" ) // ObservableModule is an optional interface that modules can implement 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_orchestrator.go b/reload_orchestrator.go new file mode 100644 index 00000000..b6b9521f --- /dev/null +++ b/reload_orchestrator.go @@ -0,0 +1,317 @@ +package modular + +import ( + "context" + "errors" + "fmt" + "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 +} + +// 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. +type ReloadOrchestrator struct { + mu sync.RWMutex + reloadables map[string]Reloadable + + requestCh chan ReloadRequest + stopCh chan struct{} + + processing atomic.Bool + + // Circuit breaker state + cbMu sync.Mutex + failures int + lastFailure time.Time + circuitOpen bool + + logger Logger + subject Subject +} + +// NewReloadOrchestrator creates a new ReloadOrchestrator with the given logger and event subject. +func NewReloadOrchestrator(logger Logger, subject Subject) *ReloadOrchestrator { + return &ReloadOrchestrator{ + reloadables: make(map[string]Reloadable), + requestCh: make(chan ReloadRequest, 100), + stopCh: make(chan struct{}), + 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 request +// channel is full or the circuit breaker is open. +func (o *ReloadOrchestrator) RequestReload(ctx context.Context, trigger ReloadTrigger, diff ConfigDiff) error { + if o.isCircuitOpen() { + return errors.New("reload circuit breaker is open; backing off") + } + select { + case o.requestCh <- ReloadRequest{Trigger: trigger, Diff: diff, Ctx: ctx}: + return nil + default: + return errors.New("reload request channel is full") + } +} + +// 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 <-o.stopCh: + return + case req, ok := <-o.requestCh: + if !ok { + return + } + // Use the request's context if provided, otherwise use the start context. + rctx := req.Ctx + if rctx == nil { + rctx = ctx + } + 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 and closes the request channel. +func (o *ReloadOrchestrator) Stop() { + close(o.stopCh) +} + +// 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 errors.New("reload already in progress") + } + defer o.processing.Store(false) + + // Emit started event. + o.emitEvent(ctx, EventTypeConfigReloadStarted, map[string]interface{}{ + "trigger": req.Trigger.String(), + "diffId": req.Diff.DiffID, + "summary": req.Diff.ChangeSummary(), + }) + + // Noop if no changes. + if !req.Diff.HasChanges() { + o.emitEvent(ctx, EventTypeConfigReloadNoop, map[string]interface{}{ + "trigger": req.Trigger.String(), + "diffId": req.Diff.DiffID, + }) + return nil + } + + // Build the list of changes for the Reloadable interface. + changes := o.buildChanges(req.Diff) + + // Snapshot current reloadables under read lock. + o.mu.RLock() + var targets []reloadEntry + for name, mod := range o.reloadables { + targets = append(targets, reloadEntry{name: name, module: mod}) + } + o.mu.RUnlock() + + // 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() + 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]interface{}{ + "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]interface{}{ + "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() + 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]interface{}) { + 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..284ea224 --- /dev/null +++ b/reload_test.go @@ -0,0 +1,474 @@ +package modular + +import ( + "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 } + +func (m *mockReloadable) getLastChanges() []ConfigChange { + m.mu.Lock() + defer m.mu.Unlock() + result := make([]ConfigChange, len(m.lastChanges)) + copy(result, m.lastChanges) + return result +} + +// 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) getEvents() []cloudevents.Event { + s.mu.Lock() + defer s.mu.Unlock() + result := make([]cloudevents.Event, len(s.events)) + copy(result, s.events) + return result +} + +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) + } + }) +} + +// --- 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, cancel := context.WithCancel(context.Background()) + defer cancel() + orch.Start(ctx) + + diff := newTestDiff() + if err := orch.RequestReload(ctx, ReloadManual, diff); err != nil { + t.Fatalf("RequestReload failed: %v", err) + } + + // Wait for processing. + time.Sleep(100 * time.Millisecond) + + if mod.reloadCalls.Load() != 1 { + t.Errorf("expected 1 reload call, got %d", mod.reloadCalls.Load()) + } + + events := subject.eventTypes() + if len(events) < 2 { + t.Fatalf("expected at least 2 events, got %d: %v", len(events), events) + } + 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, cancel := context.WithCancel(context.Background()) + defer cancel() + orch.Start(ctx) + + diff := newTestDiff() + if err := orch.RequestReload(ctx, ReloadManual, diff); err != nil { + t.Fatalf("RequestReload failed: %v", err) + } + + time.Sleep(200 * time.Millisecond) + + // Because map iteration order is not deterministic, we check both scenarios. + // If mod1 was applied first and mod2 failed, mod1 gets a rollback call (2 total). + // If mod2 was applied first and failed immediately, mod1 never ran (0 calls). + calls1 := mod1.reloadCalls.Load() + calls2 := mod2.reloadCalls.Load() + + // mod2 must have been called at least once (the failing attempt). + if calls2 < 1 { + t.Errorf("expected mod2 to be called at least once, got %d", calls2) + } + + // If mod1 was called before mod2, it should have been called twice (original + rollback). + if calls1 > 0 && calls1 != 2 { + t.Errorf("if mod1 was called, expected 2 calls (apply+rollback), got %d", calls1) + } + + // 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, cancel := context.WithCancel(context.Background()) + defer cancel() + orch.Start(ctx) + + diff := newTestDiff() + + // Trigger enough failures to open the circuit breaker. + for i := 0; i < circuitBreakerThreshold; i++ { + if err := orch.RequestReload(ctx, ReloadManual, diff); err != nil { + t.Fatalf("RequestReload %d failed: %v", i, err) + } + time.Sleep(100 * time.Millisecond) + } + + // 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, cancel := context.WithCancel(context.Background()) + defer cancel() + orch.Start(ctx) + + diff := newTestDiff() + if err := orch.RequestReload(ctx, ReloadManual, diff); err != nil { + t.Fatalf("RequestReload failed: %v", err) + } + + time.Sleep(100 * time.Millisecond) + + if mod.reloadCalls.Load() != 0 { + t.Errorf("expected 0 reload calls for disabled module, got %d", mod.reloadCalls.Load()) + } + + // Should still emit completed (no modules failed). + hasCompleted := false + for _, et := range subject.eventTypes() { + if et == EventTypeConfigReloadCompleted { + hasCompleted = true + } + } + if !hasCompleted { + t.Error("expected ConfigReloadCompleted event even when modules skipped") + } +} + +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, cancel := context.WithCancel(context.Background()) + defer cancel() + orch.Start(ctx) + + diff := newTestDiff() + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = orch.RequestReload(ctx, ReloadManual, diff) + }() + } + wg.Wait() + + // Give time for all queued reloads to process. + time.Sleep(500 * time.Millisecond) + + calls := mod.reloadCalls.Load() + if calls < 1 { + t.Errorf("expected at least 1 reload call, got %d", calls) + } + // 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, cancel := context.WithCancel(context.Background()) + defer cancel() + 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) + } + + time.Sleep(100 * time.Millisecond) + + if mod.reloadCalls.Load() != 0 { + t.Errorf("expected 0 reload calls for empty diff, got %d", mod.reloadCalls.Load()) + } + + hasNoop := false + for _, et := range subject.eventTypes() { + if et == EventTypeConfigReloadNoop { + hasNoop = true + } + } + if !hasNoop { + t.Error("expected ConfigReloadNoop event for empty diff") + } +} 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..db9117b3 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 diff --git a/tenant_guard.go b/tenant_guard.go new file mode 100644 index 00000000..8721aeb1 --- /dev/null +++ b/tenant_guard.go @@ -0,0 +1,278 @@ +package modular + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + cloudevents "github.com/cloudevents/sdk-go/v2" +) + +// 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 logs the violation but allows the operation to proceed. + 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 custom logger on the guard. +func WithTenantGuardLogger(l *log.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 + violations []TenantViolation + head int + count int + mu sync.RWMutex + logger *log.Logger + subject Subject +} + +// NewStandardTenantGuard creates a new StandardTenantGuard with the given config and options. +func NewStandardTenantGuard(config TenantGuardConfig, opts ...TenantGuardOption) *StandardTenantGuard { + if config.MaxViolations <= 0 { + config.MaxViolations = 1000 + } + + g := &StandardTenantGuard{ + config: config, + 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 + if targets, ok := g.config.Whitelist[violation.TenantID]; ok { + for _, t := range targets { + if t == violation.TargetID { + 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.Printf("tenant violation: type=%s severity=%s tenant=%s target=%s details=%s", + violation.Type, violation.Severity, violation.TenantID, violation.TargetID, violation.Details) + } + + // Emit event if subject is available + if g.subject != nil { + event := cloudevents.NewEvent() + event.SetType(EventTypeTenantViolation) + event.SetSource("com.modular.tenant.guard") + event.SetTime(violation.Timestamp) + _ = event.SetData(cloudevents.ApplicationJSON, violation) + _ = g.subject.NotifyObservers(ctx, event) + } + + // 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..469d3d13 --- /dev/null +++ b/tenant_guard_test.go @@ -0,0 +1,325 @@ +package modular + +import ( + "bytes" + "context" + "errors" + "log" + "sync" + "testing" + "time" +) + +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 + + var buf bytes.Buffer + logger := log.New(&buf, "", 0) + 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 buf.Len() == 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 := 0; i < 8; i++ { + _ = 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 := 0; i < 100; i++ { + 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/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"