diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..888324f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,45 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true + +# Go files +[*.go] +indent_style = tab +indent_size = 4 + +# Go mod files +[go.{mod,sum}] +indent_style = tab +indent_size = 4 + +# YAML files +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +# JSON files +[*.json] +indent_style = space +indent_size = 2 + +# Markdown files +[*.md] +trim_trailing_whitespace = false + +# Nix files +[*.nix] +indent_style = space +indent_size = 2 + +# Shell scripts +[*.sh] +indent_style = space +indent_size = 2 diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..370921b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,141 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate: + name: Validate Flake + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: cachix/install-nix-action@v31 + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Validate flake + run: nix flake check --no-build + + check: + name: ${{ matrix.check }} + runs-on: ubuntu-latest + needs: [validate] + strategy: + fail-fast: false + matrix: + check: [fmt-check, tidy, lint] + steps: + - uses: actions/checkout@v6 + + - uses: cachix/install-nix-action@v31 + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Run ${{ matrix.check }} + run: nix run .#${{ matrix.check }} + + - name: Verify no uncommitted changes + if: matrix.check == 'tidy' + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "::error::Run 'nix run .#tidy' and commit changes" + git diff + exit 1 + fi + + unit-test: + name: Unit Tests (Race) + runs-on: ubuntu-latest + needs: [check] + steps: + - uses: actions/checkout@v6 + + - uses: cachix/install-nix-action@v31 + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Run unit tests with race detector + run: nix run .#test-unit + + - name: Upload coverage artifact + uses: actions/upload-artifact@v7 + with: + name: coverage-unit + path: coverage-unit.out + retention-days: 1 + + integration-test: + name: Integration Tests (Race) + runs-on: ubuntu-latest + needs: [check] + steps: + - uses: actions/checkout@v6 + + - uses: cachix/install-nix-action@v31 + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Run integration tests with race detector + run: nix run .#test-integration + + - name: Upload coverage artifact + uses: actions/upload-artifact@v7 + with: + name: coverage-integration + path: coverage-integration.out + retention-days: 1 + + report: + name: Coverage Report + runs-on: ubuntu-latest + needs: [unit-test, integration-test] + if: always() && (needs.unit-test.result == 'success' || needs.integration-test.result == 'success') + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v6 + + - name: Download unit test coverage + if: needs.unit-test.result == 'success' + uses: actions/download-artifact@v8 + with: + name: coverage-unit + + - name: Download integration test coverage + if: needs.integration-test.result == 'success' + uses: actions/download-artifact@v8 + with: + name: coverage-integration + + - name: Upload unit coverage to Codecov + if: needs.unit-test.result == 'success' + uses: codecov/codecov-action@v6 + with: + use_oidc: true + fail_ci_if_error: true + files: ./coverage-unit.out + flags: unittests + name: synthra-unit + disable_search: true + + - name: Upload integration coverage to Codecov + if: needs.integration-test.result == 'success' + uses: codecov/codecov-action@v6 + with: + use_oidc: true + fail_ci_if_error: true + files: ./coverage-integration.out + flags: integration + name: synthra-integration + disable_search: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f806ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.direnv/ +.pre-commit-config.yaml +coverage.out +coverage-unit.out +coverage-integration.out +coverage.html diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..ba47165 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,359 @@ +# Copyright 2026 The Gopherly Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# golangci-lint configuration (HARD/REQUIRED checks) +# This config contains only critical linters that must pass. +version: "2" + +linters: + enable: + # === SECURITY === + # bidichk: Detects dangerous Unicode bidirectional text (security) + # Prevents invisible characters that can hide malicious code + - bidichk + + # gosec: Security vulnerability scanner + # Detects SQL injection, hardcoded creds, path traversal, bad TLS, etc. + - gosec + + # === CRITICAL BUGS === + # errcheck: Ensures errors are checked (critical bugs) + # BAD: file.Close() + # GOOD: if err := file.Close(); err != nil { return err } + - errcheck + + # nilnesserr: Enhanced nilerr - catches complex nil/error mismatches + # BAD: if err != nil { return nil, differentErr } + # GOOD: if err != nil { return nil, err } + - nilnesserr + + # nilnil: Catches returning nil error with nil/invalid value + # BAD: func GetUser(id int) (*User, error) { return nil, nil } + # GOOD: func GetUser(id int) (*User, error) { return nil, ErrNotFound } + - nilnil + + # staticcheck: The gold standard Go linter (has autofix) + # Catches 100+ bug patterns, style issues, and simplifications + - staticcheck + + # govet: Standard Go vet checks (extended) + # Catches printf errors, mutex copies, struct tag issues, shadowing, etc. + - govet + + # bodyclose: Ensures HTTP response bodies are closed (resource leaks) + # BAD: resp, _ := http.Get(url); return resp.StatusCode + # GOOD: resp, _ := http.Get(url); defer resp.Body.Close() + - bodyclose + + # forcetypeassert: Catches unsafe type assertions that can panic + # BAD: s := v.(string) // panics if v is not string + # GOOD: s, ok := v.(string); if !ok { return err } + - forcetypeassert + + # === CORRECTNESS === + # errorlint: Ensures proper Go 1.13+ error handling + # BAD: if err == ErrNotFound { } // breaks with wrapped errors + # GOOD: if errors.Is(err, ErrNotFound) { } + - errorlint + + # contextcheck: Ensures context is properly propagated + # BAD: func Handle(ctx context.Context) { doWork(context.Background()) } + # GOOD: func Handle(ctx context.Context) { doWork(ctx) } + - contextcheck + + # fatcontext: Detects nested contexts in loops (memory leak) + # BAD: for _, item := range items { ctx = context.WithValue(ctx, k, item) } + # GOOD: for _, item := range items { itemCtx := context.WithValue(ctx, k, item) } + - fatcontext + + # spancheck: Validates OpenTelemetry span handling + # BAD: span := tracer.Start(ctx, "op"); /* no span.End() */ + # GOOD: defer span.End(); span.RecordError(err); span.SetStatus(codes.Error) + - spancheck + + # durationcheck: Catches duration * duration bugs + # BAD: timeout := time.Second * time.Second // nonsense: 1e18 ns! + # GOOD: timeout := 5 * time.Second + - durationcheck + + # makezero: Catches slice initialization bugs + # BAD: s := make([]int, 5); s = append(s, 1) // s has 6 elements, not 1! + # GOOD: s := make([]int, 0, 5); s = append(s, 1) + - makezero + + # reassign: Prevents reassignment of package-level variables + # BAD: io.EOF = errors.New("custom") // breaks all io.EOF checks! + # GOOD: Don't reassign package variables + - reassign + + # revive: Style + API hygiene rules + # Used here for unexported return type checks on exported funcs. + - revive + + # forbidigo: Forbid risky textual patterns + # Used here to block `any` in exported function signatures. + - forbidigo + + # godoclint: Go doc comment practice (godoc-lint via golangci-lint). + # Aligns with docs/documentation-standards.md. Rules that duplicate + # staticcheck ST1020–ST1022 or revive package-comments are disabled below. + - godoclint + + # gocritic (go-critic): Extra bug/perf/style diagnostics beyond vet/staticcheck. + # Tags add checks on top of go-critic defaults; performance checks are off by default + # in go-critic unless explicitly enabled. Style tag omitted here (overlap with revive). + # https://go-critic.com/overview.html + - gocritic + + # modernize: Suggest simplifications using modern Go/stdlib (golangci-lint ≥ v2.6.0). + # Disabled analyzers below align with forbidigo (`any`) and conservative API choices. + - modernize + + # === HYGIENE / CONSISTENCY === + # gomoddirectives: go.mod directive hygiene (replace, retract, toolchain, etc.). + - gomoddirectives + + # nolintlint: Ill-formed //nolint directives; keeps suppressions specific and effective. + - nolintlint + + # thelper: Test helpers should call t.Helper() so failures point at call sites (testing-standards.md). + - thelper + + # paralleltest: Unsafe t.Parallel() patterns; ignore-missing avoids requiring Parallel everywhere. + - paralleltest + + # loggercheck: slog (and optional libs) key/value pairing at call sites. + - loggercheck + + # usestdlibvars: Prefer stdlib named constants (HTTP methods/status, time layouts, etc.). + - usestdlibvars + + # mirror: Prefer bytes/strings stdlib helpers where appropriate (may overlap modernize). + - mirror + + # misspell / dupword: Typos and duplicated words (comments-focused where configured below). + - misspell + - dupword + + settings: + bidichk: + # All Unicode bidirectional overrides are dangerous - check all + left-to-right-embedding: true + right-to-left-embedding: true + pop-directional-formatting: true + left-to-right-override: true + right-to-left-override: true + left-to-right-isolate: true + right-to-left-isolate: true + first-strong-isolate: true + pop-directional-isolate: true + + errcheck: + # Check type assertions: a := b.(Type) can panic if wrong + check-type-assertions: true + # Check blank assignments: _, _ = funcReturningError() + check-blank: true + # Don't exclude common functions - be thorough + disable-default-exclusions: false + exclude-functions: [] + verbose: true + + errorlint: + # Check fmt.Errorf uses %w for wrapping (preserves error chain) + errorf: true + # Check type assertions use errors.As instead of type assertion + asserts: true + # Check comparisons use errors.Is instead of == + comparison: true + + fatcontext: + # Check struct pointers for context accumulation + check-struct-pointers: false + + gosec: + # Filter by severity (low, medium, high) + severity: medium + # Filter by confidence (low, medium, high) + confidence: medium + + gomoddirectives: + replace-local: false + retract-allow-no-explanation: false + check-module-path: true + + govet: + enable: + # Variable shadowing detection + - shadow + # Nil pointer dereference checks + - nilness + # Unused writes detection + - unusedwrite + settings: + shadow: + # Strict shadowing checks + strict: true + + makezero: + # Strict mode: only allow make([]T, 0) or make([]T, 0, cap) + always: true + + nilnil: + # Also catch: return validValue, someError (opposite problem) + detect-opposite: true + + reassign: + # Check ALL package variables + patterns: + - ".*" + + revive: + rules: + # Prevent exported functions/methods from returning unexported types. + # This catches patterns like: func WithStringPrompt(...) anUnexportedType + - name: unexported-return + + # Require a package-level comment that starts with "Package ". + # Enforces docs/documentation-standards.md "Package Documentation (doc.go)". + - name: package-comments + + # Enforce Go naming conventions for variables, parameters, and types + # (camelCase, common initialism casing like URL/HTTP/ID, no underscores). + - name: var-naming + + # Note: revive's `exported` rule overlaps with staticcheck's ST1020/ST1021/ST1022 + # (which are enabled by `staticcheck.checks: ["all"]` above). We rely on + # staticcheck for the "exported X must have a doc starting with X" check + # to avoid duplicate warnings. + + forbidigo: + # Ban exported function signatures that use `any`. + # Keep internal use of `any` allowed. + forbid: + - pattern: "^func\\s+\\([^)]+\\)\\s+[A-Z][A-Za-z0-9_]*\\([^)]*\\bany\\b" + - pattern: "^func\\s+[A-Z][A-Za-z0-9_]*\\([^)]*\\bany\\b" + + godoclint: + default: all + options: + max-len: + length: 80 + disable: + # staticcheck ST1021/ST1022: comment should start with symbol name. + - start-with-name + # staticcheck ST1020: exported identifiers must have a doc comment. + - require-doc + + gocritic: + enabled-tags: + - diagnostic + - performance + # lipgloss.Style, theme/slog types are large by design; value receivers and + # range over spec slices are intentional for this API. + disabled-checks: + - hugeParam + - rangeValCopy + + modernize: + # By default all analyzers run; these are opted out (see modernize docs). + disable: + - any # Conflicts with forbidigo on exported signatures; keep interface{} where used. + + spancheck: + checks: + # Ensure span.End() is called (usually via defer) + - end + # Ensure span.RecordError(err) when returning error + - record-error + # Ensure span.SetStatus(codes.Error, msg) when returning error + - set-status + + staticcheck: + # Enable all checks (SA*, S*, ST*, QF*) + checks: ["all"] + + nolintlint: + # Fail unused //nolint and require naming the suppressed linter (no bare //nolint). + allow-unused: false + allow-no-explanation: [] + require-explanation: false + require-specific: true + + paralleltest: + # Only flag incorrect Parallel usage; do not require t.Parallel on every test/subtest. + ignore-missing: true + ignore-missing-subtests: true + + loggercheck: + # Nabat uses log/slog; disable checks for loggers not used in-tree. + kitlog: false + klog: false + logr: false + slog: true + zap: false + + misspell: + locale: US + mode: restricted + # Docs use British spellings and proper nouns; US locale otherwise. + ignore-rules: + - colour + - spectre + + dupword: + comments-only: true + + thelper: + # *testing.T / *testing.B: require t.Helper() at start of helpers. + # testing.TB: nabattest.Run allows nil tb (examples); first stmt cannot be tb.Helper(). + tb: + begin: false + +formatters: + enable: + # gofumpt: Stricter gofmt with additional consistency rules + - gofumpt + + # gci: Import statement organization with granular control + - gci + + settings: + gofumpt: + # Module path for determining local imports + module-path: nabat.dev + # Enable additional formatting rules beyond gofmt + extra-rules: true + + gci: + # Import section order + sections: + - standard # Standard library packages + - default # Third-party packages + - prefix(nabat.dev) # All nabat.dev/* local modules + - blank # Blank imports (side effects) + - alias # Aliased imports + custom-order: true + no-inline-comments: true + no-prefix-comments: true + +issues: + # Report all critical issues + max-issues-per-linter: 0 + max-same-issues: 0 + # Don't auto-fix by default (CI should detect, not modify) + fix: false + +run: + # Timeout for total work + timeout: 5m \ No newline at end of file diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..ff6b1b2 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,21 @@ +{ + "default": true, + "MD013": false, + "MD010": { + "code_blocks": false + }, + "MD024": { + "siblings_only": true + }, + "MD033": { + "allowed_elements": ["br", "details", "summary", "kbd", "sub", "sup"] + }, + "MD041": false, + "MD046": { + "style": "fenced" + }, + "MD007": { + "indent": 2 + }, + "MD060": false +} \ No newline at end of file diff --git a/README.md b/README.md index e70d13e..c51e59e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,712 @@ -# idiolect -Configuration package for Go — give your application its unique voice. +# Synthra + +[![CI](https://github.com/gopherly/synthra/actions/workflows/ci.yml/badge.svg)](https://github.com/gopherly/synthra/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/gopherly/synthra/branch/main/graph/badge.svg)](https://codecov.io/gh/gopherly/synthra) +[![Go Reference](https://pkg.go.dev/badge/gopherly.dev/synthra.svg)](https://pkg.go.dev/gopherly.dev/synthra) +[![Go Report Card](https://goreportcard.com/badge/gopherly.dev/synthra)](https://goreportcard.com/report/gopherly.dev/synthra) +[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE) + +**From many sources, one config.** + +Synthra is a Go library that builds one configuration from many places. It reads from files, environment variables, Consul, in-memory bytes, and any custom source. It merges them in order, validates the result, and binds it to a struct if you want. The name comes from the Greek word *synthesis*, which means "to put together." + +```bash +go get gopherly.dev/synthra +``` + +Requires Go 1.26 or later. + +```go +import "gopherly.dev/synthra" +``` + +## How it works + +```mermaid +flowchart LR + S1[File] --> Merge + S2[Env] --> Merge + S3[Consul] --> Merge + S4[Custom] --> Merge + Merge --> Validate + Validate --> Bind["Bind to struct"] + Bind --> Ready["Synthra ready"] + Ready --> Read["Get / String / Int / ..."] + Ready --> Dump["Dump to file"] + + style S1 fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f + style S2 fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f + style S3 fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f + style S4 fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f + style Merge fill:#fef3c7,stroke:#f59e0b,color:#78350f + style Validate fill:#fef3c7,stroke:#f59e0b,color:#78350f + style Bind fill:#fef3c7,stroke:#f59e0b,color:#78350f + style Ready fill:#d1fae5,stroke:#10b981,color:#064e3b + style Read fill:#ede9fe,stroke:#8b5cf6,color:#3b0764 + style Dump fill:#ede9fe,stroke:#8b5cf6,color:#3b0764 +``` + +## Why Synthra + +Most Go services load configuration from more than one place. A YAML file holds the defaults, environment variables override them in production, and a key-value store like Consul holds shared settings. Synthra makes this simple: + +- One small API for all sources. +- Clear merge order: later sources win over earlier ones. +- Twelve-Factor friendly environment support with clear source precedence, so config can move cleanly across environments. +- Format detection from the file extension (`.yaml`, `.yml`, `.json`, `.toml`). +- Struct binding with type conversion, default values, and validation. +- Case-insensitive keys with dot notation (`server.port`). +- Safe for use from many goroutines at the same time. +- Small core, optional extras. Consul is the only heavy dependency; it ships with the module, but you only interact with it if you call `WithConsul` (optionally wrapped with `WithIf`). +- A `synthratest` helper package for tests. + +## Contents + +1. [How it works](#how-it-works) +2. [Quick start](#quick-start) +3. [Sources](#sources) +4. [Formats](#formats) +5. [Struct binding](#struct-binding) +6. [Default values](#default-values) +7. [Validation](#validation) +8. [Reading values](#reading-values) +9. [Merge order and precedence](#merge-order-and-precedence) +10. [Case insensitivity and dot notation](#case-insensitivity-and-dot-notation) +11. [Environment variable naming](#environment-variable-naming) +12. [Dumping configuration](#dumping-configuration) +13. [Testing helpers](#testing-helpers) +14. [Custom sources and codecs](#custom-sources-and-codecs) +15. [Error handling](#error-handling) +16. [Thread safety](#thread-safety) +17. [Examples](#examples) +18. [License](#license) +19. [Contributing](#contributing) + +## Quick start + +Create a `config.yaml` file: + +```yaml +server: + host: "localhost" + port: 8080 +debug: true +``` + +Then load it: + +```go +package main + +import ( + "context" + "fmt" + "log" + + "gopherly.dev/synthra" +) + +type Config struct { + Server struct { + Host string `synthra:"host"` + Port int `synthra:"port"` + } `synthra:"server"` + Debug bool `synthra:"debug"` +} + +func main() { + var cfg Config + + s := synthra.MustNew( + synthra.WithFile("config.yaml"), + synthra.WithEnv("APP_"), + synthra.WithBinding(&cfg), + ) + + if err := s.Load(context.Background()); err != nil { + log.Fatal(err) + } + + fmt.Printf("listening on %s:%d (debug=%v)\n", + cfg.Server.Host, cfg.Server.Port, cfg.Debug) +} +``` + +Set `APP_SERVER_PORT=9090` to override the YAML port at runtime. + +## Sources + +A source is any type whose `Load` method returns a `map[string]any`. Synthra ships several built-in sources. + +### File with automatic format detection + +The format comes from the file extension. Supported extensions: `.yaml`, `.yml`, `.json`, `.toml`. + +```go +synthra.WithFile("config.yaml") +synthra.WithFile("config.json") +synthra.WithFile("config.toml") +``` + +Paths support shell-style environment variable expansion: `${VAR}` or `$VAR`. + +```go +synthra.WithFile("${CONFIG_DIR}/app.yaml") +``` + +### File with explicit format + +Use this when the file has no extension, or when the extension does not match the real format. + +```go +import "gopherly.dev/synthra/codec" + +synthra.WithFileAs("config", codec.YAML) +synthra.WithFileAs("config.dat", codec.JSON) +``` + +### File inside an `io/fs.FS` + +Useful for embedded files (`embed.FS`) and tests (`testing/fstest.MapFS`). + +```go +import ( + "embed" + "gopherly.dev/synthra" +) + +//go:embed config.yaml +var configFS embed.FS + +s := synthra.MustNew( + synthra.WithFileFS(configFS, "config.yaml"), +) +``` + +You can also use `WithFileFSAs` to pass an explicit decoder. + +### Environment variables + +Pick a prefix. Synthra reads every variable with that prefix, removes it, lowercases the rest, and splits on `_` to build a nested map. + +```go +synthra.WithEnv("APP_") +``` + +`APP_SERVER_PORT=8080` becomes `server.port = "8080"`. + +See [Environment variable naming](#environment-variable-naming) for the full rules. + +### In-memory content + +Pass raw bytes and a decoder. Good for baked-in defaults. + +```go +defaults := []byte(` +server: + port: 3000 +`) + +synthra.WithContent(defaults, codec.YAML) +``` + +### Consul key-value store + +Reads a key from Consul and decodes the value. The format is detected from the path, like for files. + +```go +synthra.WithConsul("production/service.yaml") +``` + +`CONSUL_HTTP_ADDR` must be set. If it is missing, `New` returns an error at construction. For dev setups where Consul may not run, gate it with `WithIf`: + +```go +synthra.WithIf(os.Getenv("CONSUL_HTTP_ADDR") != "", + synthra.WithConsul("production/service.yaml"), +) +``` + +This pattern does nothing when `CONSUL_HTTP_ADDR` is not set. The token, if any, comes from `CONSUL_HTTP_TOKEN`. + +Use `WithConsulAs` when the path has no extension, or when the extension does not match the format: + +```go +synthra.WithConsulAs("production/service", codec.JSON) +synthra.WithIf(os.Getenv("CONSUL_HTTP_ADDR") != "", + synthra.WithConsulAs("production/service", codec.JSON), +) +``` + +### Custom source + +Implement the `Source` interface and pass it through `WithSource`: + +```go +type Source interface { + Load(ctx context.Context) (map[string]any, error) +} + +s := synthra.MustNew( + synthra.WithSource(mySource), +) +``` + +The `source.NewMap` helper is useful for tests and embedded trees: + +```go +import "gopherly.dev/synthra/source" + +s := synthra.MustNew( + synthra.WithSource(source.NewMap(map[string]any{ + "server": map[string]any{"port": 8080}, + })), +) +``` + +## Formats + +The `codec` package ships ready-to-use codecs: + +| Codec | Reads | Writes | +|-------|-------|--------| +| `codec.YAML` | yes | yes | +| `codec.JSON` | yes | yes | +| `codec.TOML` | yes | yes | +| `codec.EnvVar` | yes | no | + +It also offers scalar decoders for single-value sources (for example, a Consul key that holds one number): + +```go +codec.ParseInt("port") // bytes -> map{"port": int(...)} +codec.ParseBool("debug") +codec.ParseString("name") +codec.ParseDuration("timeout") +codec.ParseTime("start") +codec.ParseAs("count", strconv.Atoi) // generic parser +``` + +## Struct binding + +Binding turns the merged map into a typed struct. Add `WithBinding` and pass a pointer to a struct. + +```go +type Config struct { + Host string `synthra:"host"` + Port int `synthra:"port"` + Timeout time.Duration `synthra:"timeout"` + Roles []string `synthra:"roles"` + URL *url.URL `synthra:"url"` +} + +var cfg Config +s := synthra.MustNew( + synthra.WithFile("config.yaml"), + synthra.WithBinding(&cfg), +) + +if err := s.Load(context.Background()); err != nil { + log.Fatal(err) +} +``` + +The default struct tag is `synthra`. You can pick another tag name: + +```go +synthra.WithTag("cfg") +``` + +Built-in type conversions: + +- Strings, numbers, and booleans through the `cast` library. +- `time.Duration` from strings like `"30s"` or `"5m"`. +- `time.Time` from RFC 3339 strings (for example `"2025-01-01T00:00:00Z"`). +- `*url.URL` from any string URL. +- Slices from comma-separated strings or YAML/JSON arrays. + +## Default values + +Use the `default` struct tag for fallback values. A default applies only when the field stays at its zero value after binding. + +```go +type Config struct { + Host string `synthra:"host" default:"localhost"` + Port int `synthra:"port" default:"8080"` + Debug bool `synthra:"debug" default:"false"` + Timeout time.Duration `synthra:"timeout" default:"30s"` +} +``` + +You can also pass in defaults as a source. This is good when you want them visible in the merged map (for example, for `Dump`): + +```go +defaults := []byte(`server: { port: 3000 }`) + +synthra.MustNew( + synthra.WithContent(defaults, codec.YAML), + synthra.WithFile("config.yaml"), + synthra.WithEnv("APP_"), +) +``` + +## Validation + +Synthra supports three ways to validate, and you can combine them. + +### 1. Validator interface on the bound struct + +Add a `Validate() error` method on your struct. Synthra calls it after binding. + +```go +type Config struct { + Port int `synthra:"port"` +} + +func (c *Config) Validate() error { + if c.Port < 1 || c.Port > 65535 { + return fmt.Errorf("port must be between 1 and 65535, got %d", c.Port) + } + return nil +} +``` + +### 2. JSON Schema + +Pass a schema as raw bytes. Synthra validates the merged map before binding. + +```go +schema := []byte(`{ + "type": "object", + "required": ["service", "port"], + "properties": { + "service": {"type": "string", "minLength": 1}, + "port": {"type": "integer", "minimum": 1, "maximum": 65535} + } +}`) + +s := synthra.MustNew( + synthra.WithFile("config.yaml"), + synthra.WithJSONSchema(schema), +) +``` + +Synthra supports JSON Schema Draft 4, Draft 6, Draft 7, Draft 2019-09, and Draft 2020-12. + +### 3. Custom validator function + +Use `WithValidator` for cross-field rules or any logic that does not fit a schema. + +```go +synthra.WithValidator(func(m map[string]any) error { + server, _ := m["server"].(map[string]any) + tls, _ := server["tls"].(map[string]any) + if enabled, _ := tls["enabled"].(bool); enabled { + if tls["cert"] == nil || tls["key"] == nil { + return errors.New("tls.cert and tls.key are required when tls.enabled is true") + } + } + return nil +}) +``` + +You can add more than one validator. Synthra runs them in order. The first error stops `Load`. + +## Reading values + +After `Load`, you have several ways to read values. + +### Bound struct (preferred for typed code) + +If you used `WithBinding`, just use the struct. + +```go +fmt.Println(cfg.Server.Host, cfg.Server.Port) +``` + +### Strict typed methods + +These return an error when the key is missing or the value cannot be converted. + +```go +port, err := s.Int("server.port") +host, err := s.String("server.host") +debug, err := s.Bool("debug") +rate, err := s.Float64("rate") +timeout, err := s.Duration("timeout") +when, err := s.Time("start_time") +tags, err := s.StringSlice("tags") +ports, err := s.IntSlice("ports") +meta, err := s.StringMap("metadata") +``` + +Use `errors.Is(err, synthra.ErrKeyNotFound)` to check for a missing key. + +### "Or" methods with a default + +These never return an error. They return the default when the key is missing or cannot be converted. + +```go +host := s.StringOr("server.host", "localhost") +port := s.IntOr("server.port", 8080) +debug := s.BoolOr("debug", false) +timeout := s.DurationOr("timeout", 30*time.Second) +tags := s.StringSliceOr("tags", []string{"default"}) +``` + +Other Or methods exist for `Int64`, `Float64`, `Time`, `IntSlice`, and `StringMap`. See the [API docs](https://pkg.go.dev/gopherly.dev/synthra) for the full list. + +### Generic `Get` and `GetOr` + +For type-safe access with one function, use the generic helpers: + +```go +port, err := synthra.Get[int](s, "server.port") +host := synthra.GetOr(s, "server.host", "localhost") +``` + +The type comes from the type parameter, or from the default value. + +### Raw access + +`Get(key)` returns `any` and `nil` when the key is missing. + +```go +v := s.Get("server.port") // any +``` + +`Values()` returns a pointer to a shallow copy of the merged map. Treat it as read-only. + +```go +all := s.Values() // *map[string]any +``` + +## Merge order and precedence + +Sources are merged in the order you add them. Later sources override earlier ones. Nested maps merge by key. Other values (strings, numbers, slices) are replaced as a whole. + +```go +synthra.MustNew( + synthra.WithContent(defaults, codec.YAML), // 1. baked-in defaults + synthra.WithFile("config.yaml"), // 2. file on disk + synthra.WithFile("override.json"), // 3. another file + synthra.WithEnv("APP_"), // 4. environment (wins) +) +``` + +In this example, environment variables have the highest precedence. + +## Case insensitivity and dot notation + +Synthra lowercases every key when it merges sources. Reads are also case-insensitive. + +```go +s.Int("server.port") // works +s.Int("Server.Port") // also works +s.Int("SERVER.PORT") // also works +``` + +Keys use dot notation: `server.port` walks into `server` and reads `port`. A key with a real dot in its name (not used as a separator) is not supported. If you store such keys, read them through `Values()`. + +## Environment variable naming + +Given prefix `APP_`: + +1. The prefix is removed. +2. The rest is lowercased. +3. Underscores split into nested keys. + +| Variable | Key | +|----------|-----| +| `APP_PORT=8080` | `port` | +| `APP_SERVER_HOST=db` | `server.host` | +| `APP_DATABASE_PRIMARY_HOST=db` | `database.primary.host` | +| `APP_TAGS=a,b,c` | `tags` (string, splits to slice on read) | + +A field like `server.read.timeout` maps to `APP_SERVER_READ_TIMEOUT` when the prefix is `APP_`. + +## Dumping configuration + +Synthra can write the merged configuration to a file. The format comes from the file extension, just like for sources. + +```go +s := synthra.MustNew( + synthra.WithFile("config.yaml"), + synthra.WithEnv("APP_"), + synthra.WithFileDumper("effective.yaml"), // format from extension +) + +s.Load(context.Background()) +s.Dump(context.Background()) // writes effective.yaml +``` + +For an explicit format: + +```go +synthra.WithFileDumperAs("output", codec.JSON) +``` + +You can also write your own dumper by implementing the `Dumper` interface and passing it with `WithDumper`. + +```go +type Dumper interface { + Dump(ctx context.Context, values *map[string]any) error +} +``` + +## Testing helpers + +The `synthratest` package provides helpers for tests. + +```go +import ( + "testing" + + "github.com/stretchr/testify/require" + "gopherly.dev/synthra" + "gopherly.dev/synthra/source" + "gopherly.dev/synthra/synthratest" +) + +func TestServer(t *testing.T) { + cfg := synthratest.Load(t, map[string]any{ + "server": map[string]any{"port": 8080, "host": "127.0.0.1"}, + }) + + port, err := cfg.Int("server.port") + require.NoError(t, err) + require.Equal(t, 8080, port) +} +``` + +Highlights: + +- `synthratest.Config(t, opts...)`: build a `*Synthra` without calling `Load`. +- `synthratest.Load(t, map, opts...)`: build and load with a map source. +- `synthratest.LoadFile(t, format, content)`: write a temp file and load it. +- `synthratest.WriteFile(t, format, content)`: write a temp config file and return its path. +- `synthratest.Dumper`: a recording dumper for tests. +- `synthratest.FuncCodec`: a codec test double with function fields for `Decode` and `Encode`. +- `synthratest.ErrSource(err)`: a source that always returns the given error. +- `synthratest.AssertString`, `AssertInt`, `AssertBool`, `AssertStringSlice`, `AssertDumped`: shortcut assertions. + +## Custom sources and codecs + +### Custom source + +Implement `Source`: + +```go +type vaultSource struct { + path string +} + +func (s *vaultSource) Load(ctx context.Context) (map[string]any, error) { + // fetch from your secret store + return map[string]any{ + "db": map[string]any{ + "password": "from-vault", + }, + }, nil +} + +synthra.WithSource(&vaultSource{path: "secret/data/db"}) +``` + +### Custom codec + +Implement `codec.Codec` (or `codec.Decoder` only if you do not need to dump): + +```go +type myCodec struct{} + +func (myCodec) Decode(data []byte, v any) error { /* ... */ } +func (myCodec) Encode(v any) ([]byte, error) { /* ... */ } + +synthra.WithFileAs("config.custom", myCodec{}) +``` + +## Error handling + +Synthra returns structured errors of type `*ConfigError`. They follow the shape of `os.PathError`: + +```go +type ConfigError struct { + Op string // "new", "load", "dump", or "get" + Path string // diagnostic locator (source index, field, schema name, ...) + Err error // the underlying cause +} +``` + +Use `errors.As` to read the operation: + +```go +if err := s.Load(ctx); err != nil { + var ce *synthra.ConfigError + if errors.As(err, &ce) { + log.Error("load failed", "op", ce.Op, "path", ce.Path, "err", ce.Err) + } + return err +} +``` + +Use `errors.Is` for fixed reasons: + +```go +_, err := s.Int("server.port") +if errors.Is(err, synthra.ErrKeyNotFound) { + return useDefaultPort() +} +``` + +Sentinel errors: + +- `synthra.ErrNilConfig`: a typed accessor was called on a nil `*Synthra`. +- `synthra.ErrKeyNotFound`: the key is missing for a strict accessor. +- `synthra.ErrNilContext`: `Load` or `Dump` got a nil context. + +`New` can return a joined error when more than one option is invalid. Use `errors.As` on it the same way. + +## Thread safety + +A `*Synthra` is safe for use by many goroutines: + +- `Load` can be called many times. The internal map is replaced atomically when loading succeeds. +- All read methods (`Get`, `String`, `Int`, `Values`, ...) hold a read lock. +- `Dump` reads a snapshot of the current values, so dumpers do not block reads. +- The bound struct is not protected. If you re-load while another goroutine reads the struct, you need your own synchronization. + +## Examples + +The [`examples/`](./examples) folder has small, runnable programs. Each one has its own README and tests. + +| Folder | Topic | +|--------|-------| +| [`basic`](./examples/basic) | YAML file and struct binding | +| [`environment`](./examples/environment) | Environment variables only | +| [`webapp`](./examples/webapp) | YAML defaults plus `WEBAPP_*` overrides, binding, and `Validate` | +| [`formats`](./examples/formats) | JSON and TOML with explicit codecs | +| [`defaults`](./examples/defaults) | Baked-in defaults, then file, then env | +| [`jsonschema`](./examples/jsonschema) | JSON Schema validation | +| [`customvalidator`](./examples/customvalidator) | Cross-field check with `WithValidator` | +| [`dump`](./examples/dump) | Writing the merged state to a file | +| [`consul`](./examples/consul) | Optional Consul source for dev and prod | +| [`testing`](./examples/testing) | Using `synthratest` and `source.NewMap` | + +Run them all with: + +```bash +go test ./examples/... +``` + +## License + +Synthra is released under the [Apache License 2.0](./LICENSE). + +## Contributing + +Contributions are welcome. Please open an issue first to discuss larger changes before sending a pull request. + +This project uses Nix for development. Run `nix develop` to enter the shell, then: + +- `nix run .#lint` to run the linter. +- `nix run .#test-unit` to run unit tests. +- `nix run .#fmt-check` to check formatting. diff --git a/codec/codec.go b/codec/codec.go new file mode 100644 index 0000000..f1a7403 --- /dev/null +++ b/codec/codec.go @@ -0,0 +1,39 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package codec + +// Encoder converts Go values into encoded byte representations. +// Implementations must be safe for concurrent use. +type Encoder interface { + // Encode converts the value v into an encoded byte slice. + // It returns an error if encoding fails. + Encode(v any) ([]byte, error) +} + +// Decoder converts encoded byte representations into Go values. +// Implementations must be safe for concurrent use. +type Decoder interface { + // Decode converts the encoded data into the value pointed to by v. + // It returns an error if decoding fails or if v is not a valid target. + Decode(data []byte, v any) error +} + +// Codec is implemented by format codecs that support both encoding and decoding. +// Scalar decoders (ParseInt, ParseBool, ParseAs) only implement Decoder. +type Codec interface { + Encoder + Decoder +} diff --git a/codec/doc.go b/codec/doc.go new file mode 100644 index 0000000..a4f889b --- /dev/null +++ b/codec/doc.go @@ -0,0 +1,59 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package codec provides encoding and decoding functionality for +// configuration data. +// +// The codec package defines [Encoder] and [Decoder] interfaces for converting +// configuration data between different formats (JSON, YAML, TOML, etc.) and +// Go types. Built-in codecs are package-level values such as [JSON], [YAML], +// and [TOML]. +// +// # Built-in Codecs +// +// The package includes built-in support for common formats: +// +// - JSON: Standard JSON encoding/decoding +// - YAML: YAML encoding/decoding +// - TOML: TOML encoding/decoding +// - EnvVar: Environment variable format +// +// # Custom Codecs +// +// Implement [Codec] (or [Decoder] alone when you only load) and pass the value +// to [gopherly.dev/synthra.WithFileAs], [gopherly.dev/synthra.WithFileFS], +// [gopherly.dev/synthra.WithFileFSAs], [gopherly.dev/synthra.WithContent], +// [gopherly.dev/synthra/source.NewFile], or +// [gopherly.dev/synthra/source.NewFileFS] as appropriate. +// +// Example with an explicit decoder and no file extension: +// +// cfg, err := synthra.New( +// synthra.WithFileAs("settings.dat", myCodec), +// ) +// +// where myCodec implements [Codec]. +// +// # Scalar Decoders +// +// The package includes scalar decoders for parsing individual values: +// +// decoder := codec.ParseInt("port") +// var m map[string]any +// decoder.Decode([]byte("8080"), &m) // m["port"] is int(8080) +// +// Supported scalar parsers include ParseBool, ParseString, ParseInt variants, +// ParseUint variants, ParseFloat variants, ParseDuration, ParseTime, and ParseAs +// for custom types. +package codec diff --git a/codec/env.go b/codec/env.go new file mode 100644 index 0000000..73630ac --- /dev/null +++ b/codec/env.go @@ -0,0 +1,83 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package codec + +import ( + "bytes" + "fmt" + "strings" +) + +// EnvVar is a Decoder that decodes the environment variable format. +var EnvVar Decoder = envVarCodec{} + +type envVarCodec struct{} + +func (envVarCodec) Decode(data []byte, v any) error { + conf := make(map[string]any) + + for env := range bytes.SplitSeq(data, []byte("\n")) { + pair := strings.SplitN(string(env), "=", 2) + if len(pair) != 2 { + continue + } + + key := strings.TrimSpace(pair[0]) + if key == "" { + continue + } + + rawParts := strings.Split(strings.ToLower(key), "_") + parts := make([]string, 0, len(rawParts)) + for _, part := range rawParts { + if part != "" { + parts = append(parts, part) + } + } + + if len(parts) == 0 { + continue + } + + current := conf + for i := 0; i < len(parts)-1; i++ { + part := parts[i] + if _, exists := current[part]; !exists { + current[part] = make(map[string]any) + } + if nextMap, ok := current[part].(map[string]any); ok { + current = nextMap + } else { + current[part] = make(map[string]any) + if innerMap, okInner := current[part].(map[string]any); okInner { + current = innerMap + } else { + return fmt.Errorf("failed to create nested map for key: %s", part) + } + } + } + + current[parts[len(parts)-1]] = strings.TrimSpace(pair[1]) + } + + ptr, ok := v.(*map[string]any) + if !ok { + return fmt.Errorf("envVarCodec.Decode: expected *map[string]any, got %T", v) + } + *ptr = conf + + return nil +} diff --git a/codec/env_test.go b/codec/env_test.go new file mode 100644 index 0000000..b020145 --- /dev/null +++ b/codec/env_test.go @@ -0,0 +1,200 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !integration + +package codec + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +// EnvVarCodecTestSuite is a test suite for the EnvVar codec. +type EnvVarCodecTestSuite struct { + suite.Suite + codec Decoder +} + +// SetupTest sets up the test suite. +func (s *EnvVarCodecTestSuite) SetupTest() { + s.codec = EnvVar +} + +// TestEnvVarCodecTestSuite runs the test suite. +func TestEnvVarCodecTestSuite(t *testing.T) { + suite.Run(t, new(EnvVarCodecTestSuite)) +} + +// TestDecode_Simple tests the decoding of simple environment variables. +func (s *EnvVarCodecTestSuite) TestDecode_Simple() { + data := []byte("FOO=bar\nBAZ=qux") + var v map[string]any + err := s.codec.Decode(data, &v) + s.NoError(err) + s.Equal("bar", v["foo"]) + s.Equal("qux", v["baz"]) +} + +// TestDecode_Nested tests the decoding of nested environment variables. +func (s *EnvVarCodecTestSuite) TestDecode_Nested() { + data := []byte("DATABASE_HOST=localhost\nDATABASE_PORT=5432\nDATABASE_USER_NAME=admin") + var v map[string]any + err := s.codec.Decode(data, &v) + s.NoError(err) + db, ok := v["database"].(map[string]any) + s.True(ok) + s.Equal("localhost", db["host"]) + s.Equal("5432", db["port"]) + user, ok := db["user"].(map[string]any) + s.True(ok) + s.Equal("admin", user["name"]) +} + +// TestDecode_Empty tests the decoding of empty environment variables. +func (s *EnvVarCodecTestSuite) TestDecode_Empty() { + data := []byte("") + var v map[string]any + err := s.codec.Decode(data, &v) + s.NoError(err) + s.Empty(v) +} + +// TestDecode_Malformed tests the decoding of malformed environment variables. +func (s *EnvVarCodecTestSuite) TestDecode_Malformed() { + data := []byte("FOO\nBAR=baz") // FOO has no '=' + var v map[string]any + err := s.codec.Decode(data, &v) + s.NoError(err) + s.Equal("baz", v["bar"]) + s.NotContains(v, "foo") +} + +// TestDecode_WrongType tests the decoding of environment variables with +// the wrong type. +func (s *EnvVarCodecTestSuite) TestDecode_WrongType() { + data := []byte("FOO=bar") + var v []string // not a *map[string]any + err := s.codec.Decode(data, &v) + s.Error(err) +} + +// TestDecode_EdgeCases_Whitespace tests the decoding of environment +// variables with whitespace. +func (s *EnvVarCodecTestSuite) TestDecode_EdgeCases_Whitespace() { + data := []byte(" FOO = bar \n\tBAZ\t=\tqux\t") + var v map[string]any + err := s.codec.Decode(data, &v) + s.NoError(err) + s.Equal("bar", v["foo"]) // whitespace trimmed from key and value + s.Equal("qux", v["baz"]) // tabs trimmed +} + +// TestDecode_EdgeCases_EmptyKey tests the decoding of environment +// variables with empty keys. +func (s *EnvVarCodecTestSuite) TestDecode_EdgeCases_EmptyKey() { + data := []byte("=value\nFOO=bar") + var v map[string]any + err := s.codec.Decode(data, &v) + s.NoError(err) + s.Equal("bar", v["foo"]) + s.NotContains(v, "") // empty key should be skipped +} + +// TestDecode_EdgeCases_UnderscoreKeys tests the decoding of environment +// variables with underscore keys. +func (s *EnvVarCodecTestSuite) TestDecode_EdgeCases_UnderscoreKeys() { + data := []byte("_=value1\n_FOO=value2\nFOO_=value3\nFOO__BAR=value4\n___=value5") + var v map[string]any + err := s.codec.Decode(data, &v) + s.NoError(err) + + // FOO__BAR should become foo.bar (empty parts filtered out) + // This overwrites any previous scalar "foo" values + foo, ok := v["foo"].(map[string]any) + s.True(ok) + s.Equal("value4", foo["bar"]) + + // Pure underscores should be completely skipped + s.NotContains(v, "") +} + +// TestDecode_EdgeCases_TypeConflicts tests the decoding of environment +// variables with type conflicts. +func (s *EnvVarCodecTestSuite) TestDecode_EdgeCases_TypeConflicts() { + // Test type conflicts: scalar vs nested + data := []byte("FOO=scalar\nFOO_BAR=nested") + var v map[string]any + err := s.codec.Decode(data, &v) + s.NoError(err) + + // The nested structure should overwrite the scalar + foo, ok := v["foo"].(map[string]any) + s.True(ok) + s.Equal("nested", foo["bar"]) +} + +// TestDecode_EdgeCases_ComplexNesting tests the decoding of environment +// variables with complex nesting. +func (s *EnvVarCodecTestSuite) TestDecode_EdgeCases_ComplexNesting() { + data := []byte("A_B_C_D=value1\nA_B_E=value2\nA_F=value3") + var v map[string]any + err := s.codec.Decode(data, &v) + s.NoError(err) + + a, ok := v["a"].(map[string]any) + s.True(ok) + + b, ok := a["b"].(map[string]any) + s.True(ok) + + c, ok := b["c"].(map[string]any) + s.True(ok) + s.Equal("value1", c["d"]) + s.Equal("value2", b["e"]) + s.Equal("value3", a["f"]) +} + +// TestDecode_EdgeCases_SingleUnderscore tests the decoding of +// environment variables with a single underscore. +func (s *EnvVarCodecTestSuite) TestDecode_EdgeCases_SingleUnderscore() { + data := []byte("_=value") + var v map[string]any + err := s.codec.Decode(data, &v) + s.NoError(err) + s.Empty(v) // Single underscore should result in empty parts and be skipped +} + +// TestDecode_FailedToCreateNestedMap tests the error path when nested map +// creation fails. +// This can occur when a key is first set as a scalar and then reused for +// nesting, and the type assertion fails when re-reading the newly created +// map (edge case in the implementation). +func (s *EnvVarCodecTestSuite) TestDecode_FailedToCreateNestedMap() { + // Feed input that creates scalar then nested under same prefix: A=scalar then A_B=nested. + // The code overwrites A with a new map; the "failed to create nested map" branch is defensive. + data := []byte("A=scalar\nA_B=nested") + var v map[string]any + err := s.codec.Decode(data, &v) + // Normal behavior: nested overwrites scalar, so we get map a with key b. + if err != nil { + s.Require().Contains(err.Error(), "failed to create nested map for key:") + return + } + a, ok := v["a"].(map[string]any) + s.True(ok) + s.Equal("nested", a["b"]) +} diff --git a/codec/json.go b/codec/json.go new file mode 100644 index 0000000..a7c70d4 --- /dev/null +++ b/codec/json.go @@ -0,0 +1,31 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package codec + +import "encoding/json" + +// JSON is a Codec that encodes and decodes JSON. +var JSON Codec = jsonCodec{} + +type jsonCodec struct{} + +func (jsonCodec) Encode(v any) ([]byte, error) { + return json.Marshal(v) +} + +func (jsonCodec) Decode(data []byte, v any) error { + return json.Unmarshal(data, v) +} diff --git a/codec/json_test.go b/codec/json_test.go new file mode 100644 index 0000000..98ebb41 --- /dev/null +++ b/codec/json_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !integration + +package codec + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +// JSONCodecTestSuite is a test suite for the JSON codec. +type JSONCodecTestSuite struct { + suite.Suite + codec Codec +} + +// SetupTest sets up the test suite. +func (s *JSONCodecTestSuite) SetupTest() { + s.codec = JSON +} + +// TestJSONCodecTestSuite runs the JSONCodecTestSuite. +func TestJSONCodecTestSuite(t *testing.T) { + suite.Run(t, new(JSONCodecTestSuite)) +} + +func (s *JSONCodecTestSuite) TestEncode() { + data := map[string]any{"foo": "bar", "num": 42} + b, err := s.codec.Encode(data) + s.NoError(err) + s.Contains(string(b), "foo") + s.Contains(string(b), "bar") + s.Contains(string(b), "num") +} + +func (s *JSONCodecTestSuite) TestEncode_Empty() { + b, err := s.codec.Encode(map[string]any{}) + s.NoError(err) + s.Equal("{}", string(b)) +} + +func (s *JSONCodecTestSuite) TestEncode_Error() { + ch := make(chan int) // not serializable + _, err := s.codec.Encode(ch) + s.Error(err) +} + +func (s *JSONCodecTestSuite) TestDecode() { + var v map[string]any + jsonStr := `{"foo": "bar", "num": 42}` + err := s.codec.Decode([]byte(jsonStr), &v) + s.NoError(err) + s.Equal("bar", v["foo"]) + s.EqualValues(42, v["num"]) +} + +func (s *JSONCodecTestSuite) TestDecode_Empty() { + var v map[string]any + err := s.codec.Decode([]byte(`{}`), &v) + s.NoError(err) + s.Empty(v) +} + +func (s *JSONCodecTestSuite) TestDecode_Error() { + var v map[string]any + err := s.codec.Decode([]byte(`{"foo":`), &v) // invalid JSON + s.Error(err) +} diff --git a/codec/scalar.go b/codec/scalar.go new file mode 100644 index 0000000..9b37f8c --- /dev/null +++ b/codec/scalar.go @@ -0,0 +1,144 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package codec + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +type parseAsDecoder[T any] struct { + key string + fn func(string) (T, error) +} + +func (d parseAsDecoder[T]) Decode(data []byte, v any) error { + m, ok := v.(*map[string]any) + if !ok { + return fmt.Errorf("codec: expected *map[string]any, got %T", v) + } + val, err := d.fn(strings.TrimSpace(string(data))) + if err != nil { + return err + } + *m = map[string]any{d.key: val} + return nil +} + +// ParseAs decodes raw bytes using fn and stores the result under the key. +func ParseAs[T any](key string, fn func(string) (T, error)) Decoder { + return parseAsDecoder[T]{key: key, fn: fn} +} + +// ParseBool decodes a bool value and stores it under the key. +func ParseBool(key string) Decoder { return ParseAs(key, strconv.ParseBool) } + +// ParseString decodes a string value and stores it under the key. +func ParseString(key string) Decoder { + return ParseAs(key, func(s string) (string, error) { return s, nil }) +} + +// ParseDuration decodes a [time.Duration] value and stores it under the key. +func ParseDuration(key string) Decoder { return ParseAs(key, time.ParseDuration) } + +// ParseInt decodes an int value and stores it under the key. +func ParseInt(key string) Decoder { return ParseAs(key, strconv.Atoi) } + +// ParseFloat64 decodes a float64 value and stores it under the key. +func ParseFloat64(key string) Decoder { + return ParseAs(key, func(s string) (float64, error) { return strconv.ParseFloat(s, 64) }) +} + +// ParseFloat32 decodes a float32 value and stores it under the key. +func ParseFloat32(key string) Decoder { + return ParseAs(key, func(s string) (float32, error) { + f, err := strconv.ParseFloat(s, 32) + return float32(f), err + }) +} + +// ParseInt64 decodes an int64 value and stores it under the key. +func ParseInt64(key string) Decoder { + return ParseAs(key, func(s string) (int64, error) { return strconv.ParseInt(s, 10, 64) }) +} + +// ParseInt32 decodes an int32 value and stores it under the key. +func ParseInt32(key string) Decoder { + return ParseAs(key, func(s string) (int32, error) { + i, err := strconv.ParseInt(s, 10, 32) + return int32(i), err + }) +} + +// ParseInt16 decodes an int16 value and stores it under the key. +func ParseInt16(key string) Decoder { + return ParseAs(key, func(s string) (int16, error) { + i, err := strconv.ParseInt(s, 10, 16) + return int16(i), err + }) +} + +// ParseInt8 decodes an int8 value and stores it under the key. +func ParseInt8(key string) Decoder { + return ParseAs(key, func(s string) (int8, error) { + i, err := strconv.ParseInt(s, 10, 8) + return int8(i), err + }) +} + +// ParseUint decodes an uint value and stores it under the key. +func ParseUint(key string) Decoder { + return ParseAs(key, func(s string) (uint, error) { + u, err := strconv.ParseUint(s, 10, 0) + return uint(u), err + }) +} + +// ParseUint64 decodes an uint64 value and stores it under the key. +func ParseUint64(key string) Decoder { + return ParseAs(key, func(s string) (uint64, error) { return strconv.ParseUint(s, 10, 64) }) +} + +// ParseUint32 decodes an uint32 value and stores it under the key. +func ParseUint32(key string) Decoder { + return ParseAs(key, func(s string) (uint32, error) { + u, err := strconv.ParseUint(s, 10, 32) + return uint32(u), err + }) +} + +// ParseUint16 decodes an uint16 value and stores it under the key. +func ParseUint16(key string) Decoder { + return ParseAs(key, func(s string) (uint16, error) { + u, err := strconv.ParseUint(s, 10, 16) + return uint16(u), err + }) +} + +// ParseUint8 decodes an uint8 value and stores it under the key. +func ParseUint8(key string) Decoder { + return ParseAs(key, func(s string) (uint8, error) { + u, err := strconv.ParseUint(s, 10, 8) + return uint8(u), err + }) +} + +// ParseTime decodes a [time.Time] value (RFC3339 format) and stores +// it under the key. +func ParseTime(key string) Decoder { + return ParseAs(key, func(s string) (time.Time, error) { return time.Parse(time.RFC3339, s) }) +} diff --git a/codec/scalar_test.go b/codec/scalar_test.go new file mode 100644 index 0000000..91a6b0b --- /dev/null +++ b/codec/scalar_test.go @@ -0,0 +1,258 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !integration + +package codec + +import ( + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseAs_CustomType(t *testing.T) { + type Celsius float64 + d := ParseAs("temp", func(s string) (Celsius, error) { + f, err := strconv.ParseFloat(s, 64) + return Celsius(f), err + }) + var m map[string]any + err := d.Decode([]byte("36.6"), &m) + require.NoError(t, err) + c, ok := m["temp"].(Celsius) + require.True(t, ok, "expected Celsius, got %T", m["temp"]) + assert.InDelta(t, 36.6, float64(c), 0.001) +} + +func TestParseAs_StrconvDirectly(t *testing.T) { + d := ParseAs("pid", strconv.Atoi) + var m map[string]any + err := d.Decode([]byte("1234"), &m) + require.NoError(t, err) + assert.Equal(t, 1234, m["pid"]) +} + +func TestParseAs_WrongVType(t *testing.T) { + d := ParseInt("x") + var notMap string + err := d.Decode([]byte("42"), ¬Map) + require.Error(t, err) + assert.Contains(t, err.Error(), "expected *map[string]any") +} + +func TestParseAs_InvalidValue(t *testing.T) { + d := ParseInt("x") + var m map[string]any + err := d.Decode([]byte("notanint"), &m) + require.Error(t, err) +} + +func TestParseAs_TrimSpace(t *testing.T) { + d := ParseInt("x") + var m map[string]any + err := d.Decode([]byte(" 42 "), &m) + require.NoError(t, err) + assert.Equal(t, 42, m["x"]) +} + +func TestParseAs_KeyIsCorrect(t *testing.T) { + d := ParseInt("mykey") + var m map[string]any + err := d.Decode([]byte("7"), &m) + require.NoError(t, err) + _, ok := m["mykey"] + assert.True(t, ok, "expected key 'mykey' in result map") + assert.Equal(t, 7, m["mykey"]) +} + +func TestParseBool(t *testing.T) { + d := ParseBool("enabled") + var m map[string]any + err := d.Decode([]byte("true"), &m) + require.NoError(t, err) + assert.Equal(t, true, m["enabled"]) +} + +func TestParseBool_False(t *testing.T) { + d := ParseBool("enabled") + var m map[string]any + err := d.Decode([]byte("false"), &m) + require.NoError(t, err) + assert.Equal(t, false, m["enabled"]) +} + +func TestParseInt(t *testing.T) { + d := ParseInt("count") + var m map[string]any + err := d.Decode([]byte("42"), &m) + require.NoError(t, err) + assert.Equal(t, 42, m["count"]) +} + +func TestParseFloat64(t *testing.T) { + d := ParseFloat64("rate") + var m map[string]any + err := d.Decode([]byte("3.14"), &m) + require.NoError(t, err) + v, ok := m["rate"].(float64) + require.True(t, ok) + assert.InDelta(t, 3.14, v, 0.0001) +} + +func TestParseString(t *testing.T) { + d := ParseString("name") + var m map[string]any + err := d.Decode([]byte("hello world"), &m) + require.NoError(t, err) + assert.Equal(t, "hello world", m["name"]) +} + +func TestParseString_Whitespace(t *testing.T) { + // ParseString trims leading/trailing whitespace before passing to fn + d := ParseString("name") + var m map[string]any + err := d.Decode([]byte(" hello "), &m) + require.NoError(t, err) + assert.Equal(t, "hello", m["name"]) +} + +func TestParseDuration(t *testing.T) { + d := ParseDuration("timeout") + var m map[string]any + err := d.Decode([]byte("1h2m3s"), &m) + require.NoError(t, err) + assert.Equal(t, 1*time.Hour+2*time.Minute+3*time.Second, m["timeout"]) +} + +func TestParseDuration_Invalid(t *testing.T) { + d := ParseDuration("timeout") + var m map[string]any + err := d.Decode([]byte("notaduration"), &m) + require.Error(t, err) +} + +func TestParseTime(t *testing.T) { + ts := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + d := ParseTime("created_at") + var m map[string]any + err := d.Decode([]byte(ts.Format(time.RFC3339)), &m) + require.NoError(t, err) + got, ok := m["created_at"].(time.Time) + require.True(t, ok) + assert.Equal(t, ts, got) +} + +func TestParseTime_Invalid(t *testing.T) { + d := ParseTime("created_at") + var m map[string]any + err := d.Decode([]byte("not-a-time"), &m) + require.Error(t, err) +} + +func TestParseUint(t *testing.T) { + d := ParseUint("n") + var m map[string]any + err := d.Decode([]byte("42"), &m) + require.NoError(t, err) + assert.Equal(t, uint(42), m["n"]) +} + +func TestParseInt64(t *testing.T) { + d := ParseInt64("bignum") + var m map[string]any + err := d.Decode([]byte("1234567890123"), &m) + require.NoError(t, err) + assert.Equal(t, int64(1234567890123), m["bignum"]) +} + +func TestParseInt32(t *testing.T) { + d := ParseInt32("n") + var m map[string]any + err := d.Decode([]byte("100"), &m) + require.NoError(t, err) + assert.Equal(t, int32(100), m["n"]) +} + +func TestParseInt16(t *testing.T) { + d := ParseInt16("n") + var m map[string]any + err := d.Decode([]byte("200"), &m) + require.NoError(t, err) + assert.Equal(t, int16(200), m["n"]) +} + +func TestParseInt8(t *testing.T) { + d := ParseInt8("n") + var m map[string]any + err := d.Decode([]byte("127"), &m) + require.NoError(t, err) + assert.Equal(t, int8(127), m["n"]) +} + +func TestParseUint64(t *testing.T) { + d := ParseUint64("n") + var m map[string]any + err := d.Decode([]byte("18446744073709551615"), &m) + require.NoError(t, err) + assert.Equal(t, uint64(18446744073709551615), m["n"]) +} + +func TestParseUint32(t *testing.T) { + d := ParseUint32("n") + var m map[string]any + err := d.Decode([]byte("4294967295"), &m) + require.NoError(t, err) + assert.Equal(t, uint32(4294967295), m["n"]) +} + +func TestParseUint16(t *testing.T) { + d := ParseUint16("n") + var m map[string]any + err := d.Decode([]byte("65535"), &m) + require.NoError(t, err) + assert.Equal(t, uint16(65535), m["n"]) +} + +func TestParseUint8(t *testing.T) { + d := ParseUint8("n") + var m map[string]any + err := d.Decode([]byte("255"), &m) + require.NoError(t, err) + assert.Equal(t, uint8(255), m["n"]) +} + +func TestParseFloat32(t *testing.T) { + d := ParseFloat32("n") + var m map[string]any + err := d.Decode([]byte("1.5"), &m) + require.NoError(t, err) + v, ok := m["n"].(float32) + require.True(t, ok) + assert.InDelta(t, 1.5, float64(v), 0.001) +} + +func TestParseAs_ResultMapOverwritten(t *testing.T) { + // Each Decode call produces a fresh single-entry map + d := ParseInt("x") + var m map[string]any + require.NoError(t, d.Decode([]byte("1"), &m)) + assert.Equal(t, 1, m["x"]) + require.NoError(t, d.Decode([]byte("2"), &m)) + assert.Equal(t, 2, m["x"]) + assert.Len(t, m, 1) +} diff --git a/codec/toml.go b/codec/toml.go new file mode 100644 index 0000000..9698f4c --- /dev/null +++ b/codec/toml.go @@ -0,0 +1,31 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package codec + +import "github.com/BurntSushi/toml" + +// TOML is a Codec that encodes and decodes TOML. +var TOML Codec = tomlCodec{} + +type tomlCodec struct{} + +func (tomlCodec) Encode(v any) ([]byte, error) { + return toml.Marshal(v) +} + +func (tomlCodec) Decode(data []byte, v any) error { + return toml.Unmarshal(data, v) +} diff --git a/codec/toml_test.go b/codec/toml_test.go new file mode 100644 index 0000000..55d72c4 --- /dev/null +++ b/codec/toml_test.go @@ -0,0 +1,84 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !integration + +package codec + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +// TOMLCodecTestSuite is a test suite for the TOML codec. +type TOMLCodecTestSuite struct { + suite.Suite + codec Codec +} + +// SetupTest sets up the test suite. +func (s *TOMLCodecTestSuite) SetupTest() { + s.codec = TOML +} + +// TestTOMLCodecTestSuite runs the TOMLCodecTestSuite. +func TestTOMLCodecTestSuite(t *testing.T) { + suite.Run(t, new(TOMLCodecTestSuite)) +} + +func (s *TOMLCodecTestSuite) TestEncode() { + data := map[string]any{"foo": "bar", "num": 42} + b, err := s.codec.Encode(data) + s.NoError(err) + s.Contains(string(b), "foo") + s.Contains(string(b), "bar") + s.Contains(string(b), "num") +} + +func (s *TOMLCodecTestSuite) TestEncode_Empty() { + b, err := s.codec.Encode(map[string]any{}) + s.NoError(err) + s.Equal("", string(b)) +} + +func (s *TOMLCodecTestSuite) TestEncode_Error() { + ch := make(chan int) // not serializable + _, err := s.codec.Encode(ch) + s.Error(err) +} + +func (s *TOMLCodecTestSuite) TestDecode() { + var v map[string]any + tomlStr := `foo = "bar" +num = 42` + err := s.codec.Decode([]byte(tomlStr), &v) + s.NoError(err) + s.Equal("bar", v["foo"]) + s.EqualValues(42, v["num"]) +} + +func (s *TOMLCodecTestSuite) TestDecode_Empty() { + var v map[string]any + err := s.codec.Decode([]byte(``), &v) + s.NoError(err) + s.Empty(v) +} + +func (s *TOMLCodecTestSuite) TestDecode_Error() { + var v map[string]any + err := s.codec.Decode([]byte(`foo = [`), &v) // invalid TOML + s.Error(err) +} diff --git a/codec/yaml.go b/codec/yaml.go new file mode 100644 index 0000000..9c59708 --- /dev/null +++ b/codec/yaml.go @@ -0,0 +1,31 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package codec + +import "github.com/goccy/go-yaml" + +// YAML is a Codec that encodes and decodes YAML. +var YAML Codec = yamlCodec{} + +type yamlCodec struct{} + +func (yamlCodec) Encode(v any) ([]byte, error) { + return yaml.Marshal(v) +} + +func (yamlCodec) Decode(data []byte, v any) error { + return yaml.Unmarshal(data, v) +} diff --git a/codec/yaml_test.go b/codec/yaml_test.go new file mode 100644 index 0000000..d280b45 --- /dev/null +++ b/codec/yaml_test.go @@ -0,0 +1,230 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package codec provides functionality for encoding and decoding data. +//go:build !integration + +package codec + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +// YAMLCodecTestSuite is a test suite for the YAML codec. +type YAMLCodecTestSuite struct { + suite.Suite + codec Codec +} + +// SetupTest sets up the test suite. +func (s *YAMLCodecTestSuite) SetupTest() { + s.codec = YAML +} + +// TestYAMLCodecTestSuite runs the YAMLCodecTestSuite. +func TestYAMLCodecTestSuite(t *testing.T) { + suite.Run(t, new(YAMLCodecTestSuite)) +} + +func (s *YAMLCodecTestSuite) TestEncode() { + data := map[string]any{"foo": "bar", "num": 42, "nested": map[string]any{"key": "value"}} + b, err := s.codec.Encode(data) + s.Require().NoError(err) + // Basic check, YAML output can vary (e.g. order of keys) + s.Assert().Contains(string(b), "foo: bar") + s.Assert().Contains(string(b), "num: 42") + s.Assert().Contains(string(b), "nested:") + s.Assert().Contains(string(b), " key: value") +} + +func (s *YAMLCodecTestSuite) TestEncode_Empty() { + b, err := s.codec.Encode(map[string]any{}) + s.Require().NoError(err) + // gopkg.in/yaml.v3 marshals an empty map to "null\n" or "{}\n" depending on context + // For consistency, we'll accept either, or just check for non-error and minimal output. + // "{}\n" is a common representation. "null\n" can also occur. + // Let's check if it's one of the expected empty representations. + strOut := string(b) + s.Assert().True(strOut == "{}\n" || strOut == "null\n" || strOut == "") +} + +func (s *YAMLCodecTestSuite) TestEncode_Error() { + // Channels are not directly serializable to YAML by default by gopkg.in/yaml.v3 + ch := make(chan int) + _, err := s.codec.Encode(ch) + s.Require().Error(err) +} + +func (s *YAMLCodecTestSuite) TestDecode() { + var v map[string]any + yamlStr := ` +foo: bar +num: 42 +nested: + key: value +` + err := s.codec.Decode([]byte(yamlStr), &v) + s.Require().NoError(err) + s.Assert().Equal("bar", v["foo"]) + s.Assert().EqualValues(42, v["num"]) + s.Require().IsType(map[string]any{}, v["nested"]) + nestedMap, ok := v["nested"].(map[string]any) + s.Require().True(ok, "nested should be map[string]any") + s.Assert().Equal("value", nestedMap["key"]) +} + +func (s *YAMLCodecTestSuite) TestDecode_Empty() { + var v map[string]any + err := s.codec.Decode([]byte(`{}`), &v) + s.Require().NoError(err) + s.Assert().Empty(v) + + var v2 map[string]any + err = s.codec.Decode([]byte(`null`), &v2) + s.Assert().Error(err) +} + +func (s *YAMLCodecTestSuite) TestDecode_Error() { + var v map[string]any + // Use a truly invalid YAML: unclosed quote + err := s.codec.Decode([]byte("foo: \"bar"), &v) + s.Assert().Error(err) +} + +func (s *YAMLCodecTestSuite) TestDecode_IntoStruct() { + type hostPortDoc struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + } + var cfg hostPortDoc + yamlStr := ` +host: localhost +port: 8080 +` + err := s.codec.Decode([]byte(yamlStr), &cfg) + s.Require().NoError(err) + s.Assert().Equal("localhost", cfg.Host) + s.Assert().Equal(8080, cfg.Port) +} + +func (s *YAMLCodecTestSuite) TestDecode_NonPointer() { + var v map[string]any + err := s.codec.Decode([]byte(`foo: bar`), v) // not a pointer + s.Assert().Error(err) +} + +func (s *YAMLCodecTestSuite) TestDecode_NonMapOrStruct() { + var v int + err := s.codec.Decode([]byte(`42`), &v) + s.Assert().NoError(err) + s.Assert().Equal(42, v) + + var arr []string + err = s.codec.Decode([]byte("- a\n- b"), &arr) + s.Assert().NoError(err) + s.Assert().Equal([]string{"a", "b"}, arr) +} + +func (s *YAMLCodecTestSuite) TestEncodeDecode_SliceAndNestedStruct() { + type Nested struct { + Name string `yaml:"name"` + } + type Parent struct { + Items []Nested `yaml:"items"` + } + in := Parent{Items: []Nested{{Name: "foo"}, {Name: "bar"}}} + b, err := s.codec.Encode(in) + s.Require().NoError(err) + var out Parent + err = s.codec.Decode(b, &out) + s.Require().NoError(err) + s.Assert().Equal(in, out) +} + +func (s *YAMLCodecTestSuite) TestEncodeDecode_BoolFloatNull() { + m := map[string]any{"b": true, "f": 3.14, "n": nil} + b, err := s.codec.Encode(m) + s.Require().NoError(err) + var out map[string]any + err = s.codec.Decode(b, &out) + s.Require().NoError(err) + s.Assert().Equal(true, out["b"]) + fVal, ok := out["f"].(float64) + s.Require().True(ok, "f should be float64, got %T", out["f"]) + s.Assert().InDelta(3.14, fVal, 0.0001) + s.Assert().Nil(out["n"]) +} + +func (s *YAMLCodecTestSuite) TestEncodeDecode_CustomType() { + type Custom struct { + Value string + } + in := Custom{Value: "custom"} + b, err := s.codec.Encode(in) + s.Require().NoError(err) + var out Custom + err = s.codec.Decode(b, &out) + s.Require().NoError(err) + s.Assert().Equal(in, out) +} + +func (s *YAMLCodecTestSuite) TestDecode_ExtraFields() { + type Target struct { + Foo string `yaml:"foo"` + } + var t Target + err := s.codec.Decode([]byte("foo: bar\nextra: ignored"), &t) + s.Require().NoError(err) + s.Assert().Equal("bar", t.Foo) +} + +func (s *YAMLCodecTestSuite) TestDecode_TypeMismatch() { + type Target struct { + Num int `yaml:"num"` + } + var t Target + err := s.codec.Decode([]byte("num: notanint"), &t) + s.Assert().Error(err) +} + +func (s *YAMLCodecTestSuite) TestEncodeDecode_UnicodeSpecialChars() { + m := map[string]any{"emoji": "😀", "special": "\u2603\nnewline\n"} + b, err := s.codec.Encode(m) + s.Require().NoError(err) + var out map[string]any + err = s.codec.Decode(b, &out) + s.Require().NoError(err) + s.Assert().Equal(m["emoji"], out["emoji"]) + s.Assert().Equal(m["special"], out["special"]) +} + +func (s *YAMLCodecTestSuite) TestDecode_DeeplyNested() { + yamlStr := "a:\n b:\n c:\n d:\n e: 1" + var v map[string]any + err := s.codec.Decode([]byte(yamlStr), &v) + s.Require().NoError(err) + // Walk down the nested structure + a, okA := v["a"].(map[string]any) + s.Require().True(okA, "a should be map[string]any") + b, okB := a["b"].(map[string]any) + s.Require().True(okB, "b should be map[string]any") + c, okC := b["c"].(map[string]any) + s.Require().True(okC, "c should be map[string]any") + d, okD := c["d"].(map[string]any) + s.Require().True(okD, "d should be map[string]any") + s.Assert().EqualValues(1, d["e"]) +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..5dbe644 --- /dev/null +++ b/doc.go @@ -0,0 +1,310 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package synthra synthesizes configuration for Go applications from many +// sources into one coherent runtime state. +// +// The name follows σύνθεσις (synthesis): to put together, to compose into a whole. +// Modern systems are configured in layers—files, environment variables, defaults, +// flags, secret stores, and remote providers. Each layer is incomplete alone; +// Synthra merges them in order (later overrides earlier), validates, binds to +// structs, and exposes the result through [Synthra]. +// **From many sources, one state.** +// +// Keys are case-insensitive; access uses dot notation. +// +// The package uses the same functional options pattern as other Gopherly packages: +// options apply to an internal config struct, and the constructor validates and +// builds the public [Synthra] from it. The returned [Synthra] is the runtime +// object used for Load, Get, and Dump. +// +// # Key Features +// +// - Multiple configuration sources (files, [io/fs.FS], environment variables, +// Consul) +// - Automatic format detection and decoding (JSON, YAML, TOML) +// - Struct binding with automatic type conversion +// - Validation using JSON Schema or custom validators +// - Case-insensitive key access with dot notation +// - Thread-safe configuration loading and access +// - Configuration dumping to files or custom destinations +// +// # Quick Start +// +// Create a configuration instance with sources. Options are applied in order; +// any validation errors are reported when the config is built (by New or MustNew). +// Options must not be nil; passing a nil option results in a validation error. +// +// cfg := synthra.MustNew( +// synthra.WithFile("config.yaml"), +// synthra.WithEnv("APP_"), +// ) +// +// Load the configuration: +// +// if err := cfg.Load(context.Background()); err != nil { +// log.Fatal(err) +// } +// +// Access configuration values (strict reads return an error if the key is +// missing or the value cannot be coerced; use *Or methods for defaults): +// +// port, err := cfg.Int("server.port") +// if err != nil { +// log.Fatal(err) +// } +// host := cfg.StringOr("server.host", "localhost") +// debug, err := cfg.Bool("debug") +// if err != nil { +// log.Fatal(err) +// } +// +// # Configuration Sources +// +// The package supports multiple configuration sources that can be combined: +// +// Files with automatic format detection: +// +// synthra.WithFile("config.yaml") // Detects YAML +// synthra.WithFile("config.json") // Detects JSON +// synthra.WithFile("config.toml") // Detects TOML +// +// Files with explicit format: +// +// synthra.WithFileAs("config", codec.YAML) +// +// Virtual files inside an [io/fs.FS] (tests, [embed.FS], etc.): +// +// synthra.WithFileFS(fsys, "config.yaml") +// synthra.WithFileFSAs(fsys, "config", codec.YAML) +// +// Environment variables with prefix: +// +// synthra.WithEnv("APP_") // Loads APP_SERVER_PORT as server.port +// +// Consul key-value store (CONSUL_HTTP_ADDR required; construction fails if unset): +// +// synthra.WithConsul("production/service.yaml") +// +// Conditional Consul (e.g. for local dev without Consul): +// +// synthra.WithIf(os.Getenv("CONSUL_HTTP_ADDR") != "", +// synthra.WithConsul("production/service.yaml"), +// ) +// +// Raw content: +// +// yamlData := []byte("port: 8080") +// synthra.WithContent(yamlData, codec.YAML) +// +// # Struct Binding +// +// Bind configuration to a struct for type-safe access: +// +// type AppConfig struct { +// Port int `synthra:"port"` +// Host string `synthra:"host"` +// Timeout time.Duration `synthra:"timeout"` +// Debug bool `synthra:"debug" default:"false"` +// } +// +// var appConfig AppConfig +// cfg := synthra.MustNew( +// synthra.WithFile("config.yaml"), +// synthra.WithBinding(&appConfig), +// ) +// +// if err := cfg.Load(context.Background()); err != nil { +// log.Fatal(err) +// } +// +// // Access typed fields directly +// fmt.Printf("Server: %s:%d\n", appConfig.Host, appConfig.Port) +// +// # Validation +// +// Validate configuration using struct methods: +// +// type Config struct { +// Port int `synthra:"port"` +// } +// +// func (c *Config) Validate() error { +// if c.Port < 1 || c.Port > 65535 { +// return fmt.Errorf("port must be between 1 and 65535") +// } +// return nil +// } +// +// Validate using JSON Schema: +// +// schema := []byte(`{ +// "type": "object", +// "properties": { +// "port": {"type": "integer", "minimum": 1, "maximum": 65535} +// }, +// "required": ["port"] +// }`) +// +// cfg := synthra.MustNew( +// synthra.WithFile("config.yaml"), +// synthra.WithJSONSchema(schema), +// ) +// +// Validate using custom functions: +// +// cfg := synthra.MustNew( +// synthra.WithFile("config.yaml"), +// synthra.WithValidator(func(values map[string]any) error { +// if port, ok := values["port"].(int); ok && port < 1 { +// return fmt.Errorf("invalid port: %d", port) +// } +// return nil +// }), +// ) +// +// # Accessing Configuration Values +// +// Type-specific methods return (value, error). Missing keys and failed +// coercions are errors; use [errors.Is] with [ErrKeyNotFound] or [ErrNilConfig] +// as needed. Methods on a nil [*Synthra] return [ErrNilConfig]. +// +// // Basic types (strict) +// port, err := cfg.Int("server.port") +// if err != nil { +// return err +// } +// host, err := cfg.String("server.host") +// if err != nil { +// return err +// } +// debug, err := cfg.Bool("debug") +// if err != nil { +// return err +// } +// rate, err := cfg.Float64("rate") +// if err != nil { +// return err +// } +// +// // Optional keys with defaults (no error when missing) +// host := cfg.StringOr("server.host", "localhost") +// port := cfg.IntOr("server.port", 8080) +// +// // Collections (strict) +// tags, err := cfg.StringSlice("tags") +// if err != nil { +// return err +// } +// ports, err := cfg.IntSlice("ports") +// if err != nil { +// return err +// } +// metadata, err := cfg.StringMap("metadata") +// if err != nil { +// return err +// } +// +// // Time-related (strict) +// timeout, err := cfg.Duration("timeout") +// if err != nil { +// return err +// } +// startTime, err := cfg.Time("start_time") +// if err != nil { +// return err +// } +// +// Generic [Get] for typed reads (same missing-key errors; primitive coercion +// matches [GetOr] for unsupported kinds): +// +// port, err := synthra.Get[int](cfg, "server.port") +// if err != nil { +// log.Fatalf("port configuration required: %v", err) +// } +// +// # Configuration Dumping +// +// Save the current configuration to a file: +// +// cfg := synthra.MustNew( +// synthra.WithFile("config.yaml"), +// synthra.WithFileDumper("output.yaml"), +// ) +// +// cfg.Load(context.Background()) +// cfg.Dump(context.Background()) // Writes to output.yaml +// +// # Thread Safety +// +// Synthra is safe for concurrent use by multiple goroutines. +// Configuration loading and reading are protected by internal locks. +// Multiple goroutines can safely call Load() and access configuration +// values simultaneously. +// +// # Escape hatches +// +// For debugging or custom serialization, [*Synthra.Values] returns a shallow +// copy of the merged top-level map. Nested maps, slices, and pointers are not +// deep-copied; do not mutate nested values—treat the snapshot as read-only. +// +// # Error Handling +// +// Construction failures, load/dump failures, and accessor type-conversion +// failures are returned as [*ConfigError], shaped like [os.PathError]: +// Op names the entrypoint ([OpNew], [OpLoad], [OpDump], or [OpGet]); Path is +// a diagnostic locator whose meaning depends on Op; Err is the cause for +// [errors.Unwrap], [errors.Is], and [errors.As]. +// +// Use [errors.As] to inspect the structured error and switch on Op: +// +// if err := cfg.Load(ctx); err != nil { +// var ce *synthra.ConfigError +// if errors.As(err, &ce) { +// switch ce.Op { +// case synthra.OpLoad: +// log.Error("load failed", "path", ce.Path, "err", ce.Err) +// } +// } +// return err +// } +// +// Use [errors.Is] for fixed outcomes such as a missing key, nil receiver, +// or nil context: +// +// _, err := cfg.Int("server.port") +// if errors.Is(err, synthra.ErrKeyNotFound) { +// return useDefaultPort() +// } +// +// [New] may return [errors.Join] of multiple [*ConfigError] values. A single +// [errors.As] finds the first in the tree; to log every construction error, +// iterate using the [errors.Join] unwrap slice (see [errors.Join]). +// +// # Examples +// +// See the examples directory for complete working examples demonstrating +// various configuration patterns and use cases including: +// +// - examples/basic — file loading and struct binding +// - examples/environment — environment-only configuration +// - examples/webapp — layered YAML + env, binding, and Validate +// - examples/jsonschema — JSON Schema validation +// - examples/customvalidator — custom validation functions +// - examples/dump — configuration dumping +// - examples/consul — optional Consul integration +// +// For more details, see the package documentation at +// https://pkg.go.dev/gopherly.dev/synthra +package synthra diff --git a/dumper.go b/dumper.go new file mode 100644 index 0000000..d3f24d8 --- /dev/null +++ b/dumper.go @@ -0,0 +1,29 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package synthra + +import "context" + +// Dumper defines the interface for configuration dumpers. +// Implementations write configuration data to various destinations +// such as files or remote services. +// +// Dump must be safe to call concurrently. +type Dumper interface { + // Dump writes the configuration values to a destination. + // The values map should not be modified by implementations. + Dump(ctx context.Context, values *map[string]any) error +} diff --git a/dumper/doc.go b/dumper/doc.go new file mode 100644 index 0000000..7553220 --- /dev/null +++ b/dumper/doc.go @@ -0,0 +1,35 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package dumper provides configuration dumper implementations. +// +// Types here satisfy [gopherly.dev/synthra.Dumper] for use with +// [gopherly.dev/synthra.WithDumper] and related options. Dumpers write +// configuration data to various destinations such as files or remote services. +// +// # Available Dumpers +// +// - File: Write configuration to files with various formats +// +// # Example +// +// Creating a file dumper: +// +// fileDumper := dumper.NewFile("output.yaml", codec.YAML) +// err := fileDumper.Dump(context.Background(), &configMap) +// +// Creating a file dumper with custom permissions: +// +// fileDumper := dumper.NewFileWithPermissions("output.yaml", codec.YAML, 0600) +package dumper diff --git a/dumper/file.go b/dumper/file.go new file mode 100644 index 0000000..1f15cd7 --- /dev/null +++ b/dumper/file.go @@ -0,0 +1,84 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dumper + +import ( + "context" + "fmt" + "os" + + "gopherly.dev/synthra/codec" +) + +// File represents a configuration dumper that writes data to a file. +// It supports customizable file permissions and uses encoders to +// convert configuration data to the appropriate format. +type File struct { + path string + encoder codec.Encoder + permissions os.FileMode +} + +const ( + // DefaultFilePermissions represents the default file permissions for + // dumped configuration files. + // Files are created with read/write permissions for the owner and read + // permissions for group and others (0644). + DefaultFilePermissions = 0o644 +) + +// NewFile creates a new File dumper that writes configuration to the +// specified file path. +// It uses default file permissions of 0644. +// The encoder parameter determines how the configuration data is formatted. +func NewFile(path string, encoder codec.Encoder) *File { + return &File{ + path: path, + encoder: encoder, + permissions: DefaultFilePermissions, + } +} + +// NewFileWithPermissions creates a new File dumper with custom file permissions. +// This allows control over the file security and access rights. +// Use this when you need more restrictive permissions (e.g., 0600 for +// sensitive configuration). +func NewFileWithPermissions(path string, encoder codec.Encoder, permissions os.FileMode) *File { + return &File{ + path: path, + encoder: encoder, + permissions: permissions, + } +} + +// Dump writes the provided configuration values to the file. +// It encodes the values using the configured encoder and writes them atomically. +// +// Errors: +// - Returns error if encoding fails +// - Returns error if writing to the file fails +func (f *File) Dump(_ context.Context, values *map[string]any) error { + data, err := f.encoder.Encode(values) + if err != nil { + return fmt.Errorf("failed to encode values: %w", err) + } + + if err = os.WriteFile(f.path, data, f.permissions); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} diff --git a/dumper/file_test.go b/dumper/file_test.go new file mode 100644 index 0000000..7f045b7 --- /dev/null +++ b/dumper/file_test.go @@ -0,0 +1,105 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !integration + +package dumper + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/stretchr/testify/suite" +) + +type FileDumperTestSuite struct { + suite.Suite + tmpFile string +} + +func (s *FileDumperTestSuite) SetupTest() { + f, err := os.CreateTemp(s.T().TempDir(), "filedumper_test_*.json") + s.Require().NoError(err) + s.tmpFile = f.Name() + s.Require().NoError(f.Close()) +} + +func (s *FileDumperTestSuite) TestDump_Success() { + encoder := &mockEncoder{} + fileDumper := NewFile(s.tmpFile, encoder) + values := &map[string]any{"foo": "bar"} + + err := fileDumper.Dump(context.Background(), values) + s.NoError(err) + + // Check file contents + data, err := os.ReadFile(s.tmpFile) + s.NoError(err) + s.Equal("encoded", string(data)) +} + +func (s *FileDumperTestSuite) TestDump_EncodeError() { + encoder := &mockEncoder{err: errors.New("encode error")} + fileDumper := NewFile(s.tmpFile, encoder) + values := &map[string]any{"foo": "bar"} + + err := fileDumper.Dump(context.Background(), values) + s.Error(err) + s.Contains(err.Error(), "encode error") +} + +func (s *FileDumperTestSuite) TestDump_FileWriteError() { + encoder := &mockEncoder{} + // Use an invalid path to force a write error + fileDumper := NewFile("/invalid/path/shouldfail.json", encoder) + values := &map[string]any{"foo": "bar"} + + err := fileDumper.Dump(context.Background(), values) + s.Error(err) + s.Contains(err.Error(), "failed to write file") +} + +func (s *FileDumperTestSuite) TestNewFileWithPermissions_DumpWritesWithCustomPermissions() { + encoder := &mockEncoder{} + fileDumper := NewFileWithPermissions(s.tmpFile, encoder, 0o600) + values := &map[string]any{"foo": "bar"} + + err := fileDumper.Dump(context.Background(), values) + s.Require().NoError(err) + + info, err := os.Stat(s.tmpFile) + s.Require().NoError(err) + s.Equal(os.FileMode(0o600), info.Mode().Perm(), "file should have custom permissions 0600") +} + +// mockEncoder implements codec.Encoder for testing +// Always returns "encoded" as bytes unless err is set + +type mockEncoder struct { + err error +} + +func (m *mockEncoder) Encode(_ any) ([]byte, error) { + if m.err != nil { + return nil, m.err + } + return []byte("encoded"), nil +} + +func TestFileDumperTestSuite(t *testing.T) { + suite.Run(t, new(FileDumperTestSuite)) +} diff --git a/error.go b/error.go new file mode 100644 index 0000000..1bb921c --- /dev/null +++ b/error.go @@ -0,0 +1,96 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package synthra + +import ( + "errors" + "fmt" +) + +// Op values identify which Synthra entrypoint produced a [ConfigError]. +// They follow the lowercase convention used by [os.PathError.Op], +// [net.OpError.Op], and [net/url.Error.Op]. +const ( + OpNew = "new" + OpLoad = "load" + OpDump = "dump" + OpGet = "get" +) + +// ErrNilConfig is returned when a typed accessor or [Get] is used on +// a nil [*Synthra]. +var ErrNilConfig = errors.New("synthra: nil Synthra") + +// ErrKeyNotFound is returned when a configuration key is missing or cannot be +// resolved for strict accessors. Errors may wrap this value; use [errors.Is] +// to detect it. +var ErrKeyNotFound = errors.New("synthra: key not found") + +// ErrNilContext is returned when [Synthra.Load] or [Synthra.Dump] is called +// with a nil [context.Context]. +var ErrNilContext = errors.New("synthra: nil context") + +// ConfigError is the structured error returned by Synthra for construction, +// load, dump, and type conversion failures at accessors. +// +// Its shape follows [os.PathError]: Op names the operation, Path locates the +// failure in a way that depends on Op (see package docs), and Err is the +// underlying cause for [errors.Unwrap], [errors.Is], and [errors.As]. +// +// Path is diagnostic text only; its format is not a stable API contract. +// Callers should branch on Op and use [errors.Is] on Err for specific reasons, +// not parse Path or [ConfigError.Error] output. +type ConfigError struct { + Op string + Path string + Err error +} + +// Error implements [error]. The format is pinned for tests: +// +// synthra: [ ]: +// +// When Path is empty, the space before Path is omitted. +func (e *ConfigError) Error() string { + if e == nil { + return "synthra: " + } + if e.Path != "" { + if e.Err != nil { + return fmt.Sprintf("synthra: %s %s: %v", e.Op, e.Path, e.Err) + } + return fmt.Sprintf("synthra: %s %s", e.Op, e.Path) + } + if e.Err != nil { + return fmt.Sprintf("synthra: %s: %v", e.Op, e.Err) + } + return fmt.Sprintf("synthra: %s", e.Op) +} + +// Unwrap returns the underlying error for [errors.Is] and [errors.As]. +func (e *ConfigError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +// NewConfigError returns a [*ConfigError]. Op should be one of [OpNew], +// [OpLoad], [OpDump], or [OpGet]. Path is the polymorphic locator described on +// [ConfigError]; use "" when none applies (for example nil-context errors). +func NewConfigError(op, path string, err error) *ConfigError { + return &ConfigError{Op: op, Path: path, Err: err} +} diff --git a/error_test.go b/error_test.go new file mode 100644 index 0000000..8b8f47d --- /dev/null +++ b/error_test.go @@ -0,0 +1,161 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !integration + +package synthra + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigError_Error(t *testing.T) { + t.Parallel() + + baseErr := errors.New("base error") + + tests := []struct { + name string + err *ConfigError + wantMsg string + }{ + { + name: "with path", + err: &ConfigError{ + Op: OpLoad, + Path: "source[0]", + Err: baseErr, + }, + wantMsg: "synthra: load source[0]: base error", + }, + { + name: "without path", + err: &ConfigError{ + Op: OpLoad, + Err: ErrNilContext, + }, + wantMsg: "synthra: load: synthra: nil context", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.wantMsg, tt.err.Error()) + }) + } +} + +func TestConfigError_Unwrap(t *testing.T) { + t.Parallel() + + baseErr := errors.New("base error") + ce := NewConfigError(OpGet, "server.port", baseErr) + assert.Equal(t, baseErr, ce.Unwrap()) +} + +func TestNewConfigError(t *testing.T) { + t.Parallel() + + baseErr := errors.New("base error") + ce := NewConfigError(OpNew, "WithFile", baseErr) + assert.Equal(t, OpNew, ce.Op) + assert.Equal(t, "WithFile", ce.Path) + assert.Equal(t, baseErr, ce.Err) +} + +func TestConfigError_IsSentinelsViaUnwrap(t *testing.T) { + t.Parallel() + + wrappedKey := fmt.Errorf("wrap: %w", ErrKeyNotFound) + ce := NewConfigError(OpGet, "k", wrappedKey) + assert.True(t, errors.Is(ce, ErrKeyNotFound)) + + ce2 := NewConfigError(OpLoad, "", ErrNilContext) + assert.True(t, errors.Is(ce2, ErrNilContext)) + + ce3 := NewConfigError(OpNew, "WithBinding", ErrNilConfig) + assert.True(t, errors.Is(ce3, ErrNilConfig)) +} + +func TestConfigError_ErrorWrapping(t *testing.T) { + t.Parallel() + + originalErr := errors.New("original error") + + t.Run("errors_Is_traverses_chain", func(t *testing.T) { + t.Parallel() + err := NewConfigError(OpGet, "port", fmt.Errorf("cast: %w", originalErr)) + assert.True(t, errors.Is(err, originalErr)) + var ce *ConfigError + require.True(t, errors.As(err, &ce)) + assert.Equal(t, OpGet, ce.Op) + }) + + t.Run("errors_As_unwrap", func(t *testing.T) { + t.Parallel() + err := NewConfigError(OpLoad, "source[1]", originalErr) + var ce *ConfigError + require.True(t, errors.As(err, &ce)) + assert.Equal(t, originalErr, ce.Unwrap()) + }) +} + +func TestConfigError_Chaining(t *testing.T) { + t.Parallel() + + originalErr := errors.New("original error") + firstErr := NewConfigError(OpLoad, "source[0]", originalErr) + secondErr := NewConfigError(OpLoad, "binding-decode", firstErr) + + var inner *ConfigError + require.True(t, errors.As(secondErr, &inner)) + assert.Equal(t, "binding-decode", inner.Path) + require.True(t, errors.Is(secondErr, originalErr)) +} + +func TestConfigError_JoinedErrors(t *testing.T) { + t.Parallel() + + e1 := NewConfigError(OpNew, "WithFile", errors.New("a")) + e2 := NewConfigError(OpNew, "WithTag", errors.New("b")) + joined := errors.Join(e1, e2) + + var first *ConfigError + require.True(t, errors.As(joined, &first)) + assert.Equal(t, OpNew, first.Op) + + unwrapMulti, ok := joined.(interface{ Unwrap() []error }) + require.True(t, ok) + children := unwrapMulti.Unwrap() + require.Len(t, children, 2) + var ce0, ce1 *ConfigError + require.True(t, errors.As(children[0], &ce0)) + require.True(t, errors.As(children[1], &ce1)) + assert.Equal(t, "WithFile", ce0.Path) + assert.Equal(t, "WithTag", ce1.Path) +} + +func TestSentinelErrors_KeyNotFoundWrapping(t *testing.T) { + t.Parallel() + + err := fmt.Errorf("lookup: %w", ErrKeyNotFound) + assert.ErrorIs(t, err, ErrKeyNotFound) +} diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..859acb2 --- /dev/null +++ b/example_test.go @@ -0,0 +1,475 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !integration + +package synthra_test + +import ( + "context" + "fmt" + "log" + + "gopherly.dev/synthra" + "gopherly.dev/synthra/codec" + "gopherly.dev/synthra/synthratest" +) + +func exampleString(cfg *synthra.Synthra, key string) string { + v, err := cfg.String(key) + if err != nil { + log.Fatal(err) + } + return v +} + +func exampleInt(cfg *synthra.Synthra, key string) int { + v, err := cfg.Int(key) + if err != nil { + log.Fatal(err) + } + return v +} + +func exampleBool(cfg *synthra.Synthra, key string) bool { + v, err := cfg.Bool(key) + if err != nil { + log.Fatal(err) + } + return v +} + +func exampleStringSlice(cfg *synthra.Synthra, key string) []string { + v, err := cfg.StringSlice(key) + if err != nil { + log.Fatal(err) + } + return v +} + +func exampleStringMap(cfg *synthra.Synthra, key string) map[string]any { + v, err := cfg.StringMap(key) + if err != nil { + log.Fatal(err) + } + return v +} + +// Example demonstrates basic configuration usage. +func Example() { + // Create config with YAML content + yamlContent := []byte(` +server: + host: localhost + port: 8080 +database: + name: mydb +`) + + cfg, err := synthra.New( + synthra.WithContent(yamlContent, codec.YAML), + ) + if err != nil { + log.Fatal(err) + } + + // Load configuration + if err = cfg.Load(context.Background()); err != nil { + log.Fatal(err) + } + + // Access values + fmt.Println(exampleString(cfg, "server.host")) + fmt.Println(exampleInt(cfg, "server.port")) + fmt.Println(exampleString(cfg, "database.name")) + + // Output: + // localhost + // 8080 + // mydb +} + +// ExampleNew demonstrates creating a new configuration instance. +func ExampleNew() { + cfg, err := synthra.New() + if err != nil { + log.Fatal(err) + } + + if err = cfg.Load(context.Background()); err != nil { + log.Fatal(err) + } + + fmt.Println("Synthra created successfully") + // Output: Synthra created successfully +} + +// ExampleMustNew demonstrates creating a configuration instance with +// panic on error. +func ExampleMustNew() { + cfg := synthra.MustNew() + if err := cfg.Load(context.Background()); err != nil { + log.Fatal(err) + } + + fmt.Println("Synthra created successfully") + // Output: Synthra created successfully +} + +// ExampleSynthra_Load demonstrates loading configuration. +func ExampleSynthra_Load() { + cfg := synthra.MustNew( + synthra.WithContent([]byte(`{"app": "example", "port": 8080}`), codec.JSON), + ) + + if err := cfg.Load(context.Background()); err != nil { + log.Fatal(err) + } + + app := exampleString(cfg, "app") + port := exampleInt(cfg, "port") + fmt.Printf("App: %s, Port: %d\n", app, port) + // Output: App: example, Port: 8080 +} + +// ExampleSynthra_Dump demonstrates writing configuration to registered dumpers. +func ExampleSynthra_Dump() { + // Create a mock dumper for demonstration + dumper := &synthratest.Dumper{} + + cfg := synthra.MustNew( + synthra.WithContent([]byte(`{"service": "api", "version": "1.0"}`), codec.JSON), + synthra.WithDumper(dumper), + ) + + if err := cfg.Load(context.Background()); err != nil { + log.Fatal(err) + } + + if err := cfg.Dump(context.Background()); err != nil { + log.Fatal(err) + } + + fmt.Println("Configuration dumped successfully") + // Output: Configuration dumped successfully +} + +// ExampleWithFile demonstrates loading configuration from a file. +func ExampleWithFile() { + // Create a temporary config file (in real code, use an actual file path) + cfg, err := synthra.New( + synthra.WithContent([]byte(`{"name": "example"}`), codec.JSON), + ) + if err != nil { + log.Fatal(err) + } + + if err = cfg.Load(context.Background()); err != nil { + log.Fatal(err) + } + + fmt.Println(exampleString(cfg, "name")) + // Output: example +} + +// ExampleWithContent demonstrates loading configuration from byte content. +func ExampleWithContent() { + jsonContent := []byte(`{ + "app": { + "name": "MyApp", + "version": "1.0.0" + } + }`) + + cfg, err := synthra.New( + synthra.WithContent(jsonContent, codec.JSON), + ) + if err != nil { + log.Fatal(err) + } + + if err = cfg.Load(context.Background()); err != nil { + log.Fatal(err) + } + + fmt.Println(exampleString(cfg, "app.name")) + fmt.Println(exampleString(cfg, "app.version")) + // Output: + // MyApp + // 1.0.0 +} + +// ExampleWithBinding demonstrates binding configuration to a struct. +func ExampleWithBinding() { + type ServerConfig struct { + Host string `synthra:"host"` + Port int `synthra:"port"` + } + + type AppConfig struct { + Server ServerConfig `synthra:"server"` + } + + yamlContent := []byte(` +server: + host: localhost + port: 8080 +`) + + var appConfig AppConfig + cfg, err := synthra.New( + synthra.WithContent(yamlContent, codec.YAML), + synthra.WithBinding(&appConfig), + ) + if err != nil { + log.Fatal(err) + } + + if err = cfg.Load(context.Background()); err != nil { + log.Fatal(err) + } + + fmt.Printf("%s:%d\n", appConfig.Server.Host, appConfig.Server.Port) + // Output: localhost:8080 +} + +// ExampleWithValidator demonstrates using a custom validator. +func ExampleWithValidator() { + yamlContent := []byte(`name: myapp`) + + cfg, err := synthra.New( + synthra.WithContent(yamlContent, codec.YAML), + synthra.WithValidator(func(cfgMap map[string]any) error { + // Custom validation logic + if _, ok := cfgMap["name"]; !ok { + return fmt.Errorf("name is required") + } + return nil + }), + ) + if err != nil { + log.Fatal(err) + } + + if err = cfg.Load(context.Background()); err != nil { + log.Fatal(err) + } + + fmt.Println("Validation passed") + // Output: Validation passed +} + +// ExampleSynthra_Get demonstrates retrieving configuration values. +func ExampleSynthra_Get() { + yamlContent := []byte(` +settings: + enabled: true + count: 42 +`) + + cfg, err := synthra.New( + synthra.WithContent(yamlContent, codec.YAML), + ) + if err != nil { + log.Fatal(err) + } + + if err = cfg.Load(context.Background()); err != nil { + log.Fatal(err) + } + + fmt.Println(cfg.Get("settings.enabled")) + fmt.Println(cfg.Get("settings.count")) + // Output: + // true + // 42 +} + +// ExampleSynthra_String demonstrates retrieving string values. +func ExampleSynthra_String() { + jsonContent := []byte(`{"name": "MyApp", "env": "production"}`) + + cfg, err := synthra.New( + synthra.WithContent(jsonContent, codec.JSON), + ) + if err != nil { + log.Fatal(err) + } + + if err = cfg.Load(context.Background()); err != nil { + log.Fatal(err) + } + + fmt.Println(exampleString(cfg, "name")) + fmt.Println(exampleString(cfg, "env")) + // Output: + // MyApp + // production +} + +// ExampleSynthra_Int demonstrates retrieving integer values. +func ExampleSynthra_Int() { + jsonContent := []byte(`{"port": 8080, "workers": 4}`) + + cfg, err := synthra.New( + synthra.WithContent(jsonContent, codec.JSON), + ) + if err != nil { + log.Fatal(err) + } + + if err = cfg.Load(context.Background()); err != nil { + log.Fatal(err) + } + + fmt.Println(exampleInt(cfg, "port")) + fmt.Println(exampleInt(cfg, "workers")) + // Output: + // 8080 + // 4 +} + +// ExampleSynthra_Bool demonstrates retrieving boolean values. +func ExampleSynthra_Bool() { + jsonContent := []byte(`{"debug": true, "verbose": false}`) + + cfg, err := synthra.New( + synthra.WithContent(jsonContent, codec.JSON), + ) + if err != nil { + log.Fatal(err) + } + + if err = cfg.Load(context.Background()); err != nil { + log.Fatal(err) + } + + fmt.Println(exampleBool(cfg, "debug")) + fmt.Println(exampleBool(cfg, "verbose")) + // Output: + // true + // false +} + +// ExampleSynthra_StringSlice demonstrates retrieving string slices. +func ExampleSynthra_StringSlice() { + yamlContent := []byte(` +tags: + - web + - api + - backend +`) + + cfg, err := synthra.New( + synthra.WithContent(yamlContent, codec.YAML), + ) + if err != nil { + log.Fatal(err) + } + + if err = cfg.Load(context.Background()); err != nil { + log.Fatal(err) + } + + tags := exampleStringSlice(cfg, "tags") + fmt.Printf("%v\n", tags) + // Output: [web api backend] +} + +// ExampleSynthra_StringMap demonstrates retrieving string maps. +func ExampleSynthra_StringMap() { + yamlContent := []byte(` +metadata: + author: John Doe + version: 1.0.0 +`) + + cfg, err := synthra.New( + synthra.WithContent(yamlContent, codec.YAML), + ) + if err != nil { + log.Fatal(err) + } + + if err = cfg.Load(context.Background()); err != nil { + log.Fatal(err) + } + + metadata := exampleStringMap(cfg, "metadata") + fmt.Println(metadata["author"]) + fmt.Println(metadata["version"]) + // Output: + // John Doe + // 1.0.0 +} + +// Example_multipleSources demonstrates merging multiple configuration sources. +func Example_multipleSources() { + // Base configuration + baseConfig := []byte(` +server: + host: localhost + port: 8080 +`) + + // Override configuration + overrideConfig := []byte(` +server: + port: 9090 +`) + + cfg, err := synthra.New( + synthra.WithContent(baseConfig, codec.YAML), + synthra.WithContent(overrideConfig, codec.YAML), + ) + if err != nil { + log.Fatal(err) + } + + if err = cfg.Load(context.Background()); err != nil { + log.Fatal(err) + } + + // Later sources override earlier ones + fmt.Println(exampleString(cfg, "server.host")) + fmt.Println(exampleInt(cfg, "server.port")) + // Output: + // localhost + // 9090 +} + +// Example_environmentVariables demonstrates loading configuration from +// environment variables. +func Example_environmentVariables() { + // In real usage, set environment variables like: + // export APP_SERVER_HOST=localhost + // export APP_SERVER_PORT=8080 + + cfg, err := synthra.New( + synthra.WithEnv("APP_"), + ) + if err != nil { + log.Fatal(err) + } + + if err = cfg.Load(context.Background()); err != nil { + log.Fatal(err) + } + + // Access environment variables without the prefix + // e.g., APP_SERVER_HOST becomes server.host + fmt.Println("Environment variables loaded") + // Output: Environment variables loaded +} diff --git a/examples/Dockerfile b/examples/Dockerfile new file mode 100644 index 0000000..a8b1b5d --- /dev/null +++ b/examples/Dockerfile @@ -0,0 +1,19 @@ +# Build the webapp example from the synthra module root: +# docker build -f examples/Dockerfile -t synthra-webapp-example . +# +# The image runs with defaults from examples/webapp/config.yaml unless +# you pass -e WEBAPP_* overrides. + +FROM golang:1.26-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/webapp ./examples/webapp + +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates +WORKDIR /app +COPY --from=build /out/webapp ./webapp +COPY examples/webapp/config.yaml ./config.yaml +ENTRYPOINT ["./webapp"] diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..3a710b0 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,60 @@ +# Synthra examples + +Runnable programs that complement the shorter snippets in [example_test.go](../example_test.go) and the package overview on [pkg.go.dev](https://pkg.go.dev/gopherly.dev/synthra). + +## Progression + +Start at the top and work down -- each example builds on concepts from the previous ones. + +| Directory | What it shows | +|-----------|---------------| +| [basic](./basic/) | YAML file + struct binding | +| [environment](./environment/) | Environment-only config | +| [webapp](./webapp/) | YAML defaults + `WEBAPP_*` overrides + binding + `Validate` | +| [jsonschema](./jsonschema/) | `WithJSONSchema` validation on a file | +| [customvalidator](./customvalidator/) | `WithValidator` cross-field rule | +| [dump](./dump/) | `WithFileDumperAs` + `Dump` of merged state | +| [defaults](./defaults/) | `WithContent` defaults, then file, then env | +| [formats](./formats/) | `WithFileAs` with JSON + TOML | +| [consul](./consul/) | `WithIf(..., WithConsul(...))` (no Consul required for tests) | +| [testing](./testing/) | `synthratest.Config` + `source.NewMap` in tests | + +## Quick start + +```bash +cd examples/basic && go run . +cd examples/environment && WEBAPP_SERVER_HOST=localhost WEBAPP_SERVER_PORT=8080 \ + WEBAPP_DATABASE_PRIMARY_HOST=db WEBAPP_DATABASE_PRIMARY_PORT=5432 \ + WEBAPP_DATABASE_PRIMARY_DATABASE=myapp WEBAPP_AUTH_JWT_SECRET=secret \ + WEBAPP_FEATURES_DEBUG_MODE=true go run . +cd examples/webapp && go run . +``` + +## Tests + +Every example ships with tests. Run them all at once: + +```bash +go test ./examples/... +``` + +## Docker (webapp) + +From the repository root: + +```bash +docker build -f examples/Dockerfile -t synthra-webapp-example . +docker run --rm synthra-webapp-example +``` + +The image uses the Go version pinned in [Dockerfile](./Dockerfile) (aligned with [go.mod](../go.mod)). + +## Environment variable naming + +Examples use explicit prefixes (`WEBAPP_`, `APP_`, `EDGE_`, `DEMO_`). Strip the prefix, split on `_`, lowercase, and nest: `WEBAPP_SERVER_READ_TIMEOUT` becomes `server.read.timeout`. + +## Adding a new example + +1. Create a subdirectory with `main.go`, `README.md`, and tests. +2. Link it from this file. +3. Keep snippets copy-paste accurate (`gopherly.dev/synthra` imports, real paths). diff --git a/examples/basic/README.md b/examples/basic/README.md new file mode 100644 index 0000000..95c1490 --- /dev/null +++ b/examples/basic/README.md @@ -0,0 +1,54 @@ +# Basic example + +Load a YAML file into a Go struct. This is the simplest way to use Synthra. + +## What it shows + +- Loading configuration from a YAML file with `WithFile` +- Binding values to a Go struct with `WithBinding` +- Automatic type conversion for `time.Duration`, `time.Time`, `*url.URL`, `bool`, and `string` +- Nested structs (the `Worker` field) +- Slices from YAML arrays (`roles`) and comma-separated strings (`types`) + +## Run + +```bash +cd examples/basic && go run . +``` + +## Expected output + +You should see `Foo:bar`, `Timeout` as `10s`, `Debug:true`, a worker address of `http://localhost:8080`, and roles `admin` / `user`. + +The struct maps the same YAML key `types` twice on purpose: `Types` (`[]string`) and `Types2` (`string`) both use `synthra:"types"` to show how Synthra decodes the same value into a slice versus keeping the raw comma-separated string. + +## Tests + +```bash +cd examples/basic && go test -v +``` + +## The config file + +`config.yaml` contains: + +```yaml +foo: bar +timeout: 10s +debug: true +date: 2025-01-01T00:00:00+01:00 +types: x1,x2,x3 +roles: + - admin + - user +worker: + timeout: 600 + address: http://localhost:8080 +``` + +## Key ideas + +1. **Struct tags** -- use `synthra:"key"` to map a YAML key to a struct field. +2. **Nesting** -- embed a struct field and give it a tag; Synthra walks into the matching YAML object automatically. +3. **Type safety** -- values are converted to the field's Go type at load time. A mismatch is an error, not a silent zero. +4. **Slices** -- YAML arrays and comma-separated strings both work. diff --git a/examples/basic/config.yaml b/examples/basic/config.yaml new file mode 100644 index 0000000..36cd3ee --- /dev/null +++ b/examples/basic/config.yaml @@ -0,0 +1,15 @@ +foo: bar +timeout: 10s +debug: true + +date: 2025-01-01T00:00:00+01:00 + +types: x1,x2,x3 + +roles: + - admin + - user + +worker: + timeout: 600 + address: http://localhost:8080 \ No newline at end of file diff --git a/examples/basic/main.go b/examples/basic/main.go new file mode 100644 index 0000000..7ebabab --- /dev/null +++ b/examples/basic/main.go @@ -0,0 +1,62 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package main shows YAML file loading and struct binding with Synthra. +package main + +import ( + "context" + "fmt" + "log" + "net/url" + "time" + + "gopherly.dev/synthra" +) + +// Config is the configuration for the application. +type Config struct { + Foo string `synthra:"foo"` + Timeout time.Duration `synthra:"timeout"` + Debug bool `synthra:"debug"` + Worker Worker `synthra:"worker"` + Date time.Time `synthra:"date"` + Roles []string `synthra:"roles"` + Types []string `synthra:"types"` + Types2 string `synthra:"types"` +} + +// Worker is the worker configuration. +type Worker struct { + Timeout time.Duration `synthra:"timeout"` + Address *url.URL `synthra:"address"` +} + +// main is the main function. +func main() { + var cfg Config + + c := synthra.MustNew( + synthra.WithFile("./config.yaml"), + synthra.WithBinding(&cfg), + ) + + err := c.Load(context.Background()) + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + fmt.Printf("%+v\n", cfg) +} diff --git a/examples/basic/main_test.go b/examples/basic/main_test.go new file mode 100644 index 0000000..22a6b27 --- /dev/null +++ b/examples/basic/main_test.go @@ -0,0 +1,44 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gopherly.dev/synthra" +) + +func TestBasic_YAMLBinding(t *testing.T) { + var cfg Config + c := synthra.MustNew( + synthra.WithFile("config.yaml"), + synthra.WithBinding(&cfg), + ) + require.NoError(t, c.Load(context.Background())) + + require.Equal(t, "bar", cfg.Foo) + require.Equal(t, 10*time.Second, cfg.Timeout) + require.True(t, cfg.Debug) + require.Equal(t, []string{"admin", "user"}, cfg.Roles) + require.Equal(t, []string{"x1", "x2", "x3"}, cfg.Types) + require.Equal(t, "x1,x2,x3", cfg.Types2) + require.Equal(t, "http://localhost:8080", cfg.Worker.Address.String()) + // worker.timeout is a bare integer in YAML; decoding follows Synthra scalar rules. + require.NotZero(t, cfg.Worker.Timeout) +} diff --git a/examples/consul/README.md b/examples/consul/README.md new file mode 100644 index 0000000..bf1f74d --- /dev/null +++ b/examples/consul/README.md @@ -0,0 +1,36 @@ +# Optional Consul layer + +Load `config.yaml` first, optionally pull a YAML value from Consul KV, then apply `EDGE_*` environment variables (highest precedence). + +When `CONSUL_HTTP_ADDR` is not set, `WithIf` turns the Consul source into a no-op -- the program still runs using file and environment only. + +## Run (without Consul) + +```bash +cd examples/consul && go run . +``` + +## Run (with Consul) + +Point `CONSUL_HTTP_ADDR` to a running Consul agent and make sure the KV path exists: + +```bash +export CONSUL_HTTP_ADDR=http://127.0.0.1:8500 +cd examples/consul && go run . +``` + +Adjust the KV path in `main.go` (`synthra/example/config.yaml`) to match your cluster. + +## Tests + +No live Consul is required -- the tests only exercise the file + env path: + +```bash +cd examples/consul && go test -v +``` + +## Key ideas + +1. **Conditional sources** -- `WithIf(condition, option)` adds a source only when the condition is true. +2. **Graceful degradation** -- the program works with or without Consul. +3. **Same merge order** -- Consul sits between file and env, so environment variables still win. diff --git a/examples/consul/config.yaml b/examples/consul/config.yaml new file mode 100644 index 0000000..8cad7ae --- /dev/null +++ b/examples/consul/config.yaml @@ -0,0 +1,3 @@ +service: + name: "edge" + port: 8080 diff --git a/examples/consul/main.go b/examples/consul/main.go new file mode 100644 index 0000000..b9e9159 --- /dev/null +++ b/examples/consul/main.go @@ -0,0 +1,50 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package main loads local YAML, optionally merges Consul KV, then env. +// +// When CONSUL_HTTP_ADDR is unset, WithIf adds no Consul source +// and the program still runs using file + environment only. +package main + +import ( + "context" + "fmt" + "log" + "os" + + "gopherly.dev/synthra" +) + +func main() { + cfg := synthra.MustNew( + synthra.WithFile("config.yaml"), + synthra.WithIf(os.Getenv("CONSUL_HTTP_ADDR") != "", synthra.WithConsul("synthra/example/config.yaml")), + synthra.WithEnv("EDGE_"), + ) + + if err := cfg.Load(context.Background()); err != nil { + log.Fatalf("load: %v", err) + } + + svcName, err := cfg.String("service.name") + if err != nil { + log.Fatalf("service.name: %v", err) + } + svcPort, err := cfg.Int("service.port") + if err != nil { + log.Fatalf("service.port: %v", err) + } + fmt.Printf("service.name=%s service.port=%d\n", svcName, svcPort) +} diff --git a/examples/consul/main_test.go b/examples/consul/main_test.go new file mode 100644 index 0000000..04b9ba6 --- /dev/null +++ b/examples/consul/main_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/require" + "gopherly.dev/synthra" +) + +func TestConsulWithIf_FileAndEnvWithoutConsul(t *testing.T) { + t.Setenv("EDGE_SERVICE_PORT", "9090") + + cfg := synthra.MustNew( + synthra.WithFile("config.yaml"), + synthra.WithIf(os.Getenv("CONSUL_HTTP_ADDR") != "", synthra.WithConsul("synthra/example/config.yaml")), + synthra.WithEnv("EDGE_"), + ) + require.NoError(t, cfg.Load(context.Background())) + svcName, err := cfg.String("service.name") + require.NoError(t, err) + require.Equal(t, "edge", svcName) + svcPort, err := cfg.Int("service.port") + require.NoError(t, err) + require.Equal(t, 9090, svcPort) +} diff --git a/examples/customvalidator/README.md b/examples/customvalidator/README.md new file mode 100644 index 0000000..f568d47 --- /dev/null +++ b/examples/customvalidator/README.md @@ -0,0 +1,48 @@ +# Custom validator (`WithValidator`) + +Run your own checks on the merged configuration map. This example enforces a rule: when `server.tls.enabled` is true, both `server.tls.cert.file` and `server.tls.key.file` must be set. + +## What it shows + +- `WithValidator` accepts a `func(map[string]any) error` +- The function receives the full merged config as a nested map +- Returning a non-nil error makes `Load` fail + +## How it works + +Synthra calls your validator after all sources are merged but before the result is committed. The map you receive looks like the YAML structure -- nested `map[string]any` values that you walk manually. + +```go +cfg, err := synthra.New( + synthra.WithFile(path), + synthra.WithValidator(tlsPathsConsistent), +) +``` + +The validator in this example (`tlsPathsConsistent`) digs into `server.tls`, checks if `enabled` is true, and returns an error when the cert or key path is empty. + +## Run + +Valid config (TLS enabled with both paths set): + +```bash +cd examples/customvalidator && go run . +``` + +Invalid config (TLS enabled but paths are empty): + +```bash +cd examples/customvalidator && go run . config-invalid.yaml +``` + +## Tests + +```bash +cd examples/customvalidator && go test -v +``` + +## Key ideas + +1. **Cross-field rules** -- unlike JSON Schema (which checks types and structure), a validator can enforce relationships between fields. +2. **Plain function** -- no interface to implement. Write a function, pass it in. +3. **Runs on the merged map** -- all sources (files, env vars, etc.) are already combined when your function runs. diff --git a/examples/customvalidator/config-invalid.yaml b/examples/customvalidator/config-invalid.yaml new file mode 100644 index 0000000..07db428 --- /dev/null +++ b/examples/customvalidator/config-invalid.yaml @@ -0,0 +1,9 @@ +server: + host: "127.0.0.1" + port: 8443 + tls: + enabled: true + cert: + file: "" + key: + file: "" diff --git a/examples/customvalidator/config.yaml b/examples/customvalidator/config.yaml new file mode 100644 index 0000000..842b96b --- /dev/null +++ b/examples/customvalidator/config.yaml @@ -0,0 +1,9 @@ +server: + host: "127.0.0.1" + port: 8443 + tls: + enabled: true + cert: + file: "/etc/ssl/certs/server.crt" + key: + file: "/etc/ssl/private/server.key" diff --git a/examples/customvalidator/main.go b/examples/customvalidator/main.go new file mode 100644 index 0000000..4c3e31f --- /dev/null +++ b/examples/customvalidator/main.go @@ -0,0 +1,81 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package main demonstrates cross-field checks with synthra.WithValidator. +package main + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "strings" + + "gopherly.dev/synthra" +) + +func tlsPathsConsistent(m map[string]any) error { + server, ok := m["server"].(map[string]any) + if !ok { + return nil + } + tls, ok := server["tls"].(map[string]any) + if !ok { + return nil + } + enabled := strings.EqualFold(fmt.Sprint(tls["enabled"]), "true") + if !enabled { + return nil + } + cert := nestedString(tls, "cert", "file") + key := nestedString(tls, "key", "file") + if cert == "" || key == "" { + return errors.New("when server.tls.enabled is true, server.tls.cert.file and server.tls.key.file must be set") + } + return nil +} + +func nestedString(m map[string]any, a, b string) string { + x, ok := m[a].(map[string]any) + if !ok { + return "" + } + v, ok := x[b].(string) + if !ok { + return fmt.Sprint(x[b]) + } + return strings.TrimSpace(v) +} + +func main() { + path := "config.yaml" + if len(os.Args) > 1 { + path = os.Args[1] + } + + cfg, err := synthra.New( + synthra.WithFile(path), + synthra.WithValidator(tlsPathsConsistent), + ) + if err != nil { + log.Fatalf("new: %v", err) + } + + if err = cfg.Load(context.Background()); err != nil { + log.Fatalf("load: %v", err) + } + + fmt.Println("TLS configuration is consistent.") +} diff --git a/examples/customvalidator/main_test.go b/examples/customvalidator/main_test.go new file mode 100644 index 0000000..2c79979 --- /dev/null +++ b/examples/customvalidator/main_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "gopherly.dev/synthra" +) + +func TestCustomValidator_AcceptsValidTLS(t *testing.T) { + cfg, err := synthra.New( + synthra.WithFile("config.yaml"), + synthra.WithValidator(tlsPathsConsistent), + ) + require.NoError(t, err) + require.NoError(t, cfg.Load(context.Background())) +} + +func TestCustomValidator_RejectsIncompleteTLS(t *testing.T) { + cfg, err := synthra.New( + synthra.WithFile("config-invalid.yaml"), + synthra.WithValidator(tlsPathsConsistent), + ) + require.NoError(t, err) + require.Error(t, cfg.Load(context.Background())) +} diff --git a/examples/defaults/README.md b/examples/defaults/README.md new file mode 100644 index 0000000..72c5db6 --- /dev/null +++ b/examples/defaults/README.md @@ -0,0 +1,33 @@ +# Embedded defaults (`WithContent`) + +Bake default values into your binary, override them with a file, then let environment variables win last. + +Sources are merged in order -- later values replace earlier ones: + +1. `WithContent` -- small YAML defaults embedded in Go code +2. `WithFile("overrides.yaml")` -- checked-in overrides +3. `WithEnv("DEMO_")` -- highest precedence + +## Run + +```bash +cd examples/defaults +DEMO_SERVER_PORT=9999 go run . +``` + +Expected output: `server.name=from-file server.port=9999` + +- `server.name` comes from `overrides.yaml` (replaced the baked-in default) +- `server.port` comes from the `DEMO_SERVER_PORT` env var (replaced the file value of `7000`) + +## Tests + +```bash +cd examples/defaults && go test -v +``` + +## Key ideas + +1. **Merge order matters** -- sources added later override earlier ones on the same key. +2. **Embedded defaults** -- `WithContent` takes a `[]byte` and a codec, so you can use `go:embed` or a literal. +3. **Three layers** -- defaults, file, environment is a common production pattern. diff --git a/examples/defaults/main.go b/examples/defaults/main.go new file mode 100644 index 0000000..20e2792 --- /dev/null +++ b/examples/defaults/main.go @@ -0,0 +1,53 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package main shows merge order: baked-in defaults, then file, then env. +package main + +import ( + "context" + "fmt" + "log" + + "gopherly.dev/synthra" + "gopherly.dev/synthra/codec" +) + +func main() { + defaults := []byte(` +server: + port: 3000 + name: "defaults-only" +`) + + cfg := synthra.MustNew( + synthra.WithContent(defaults, codec.YAML), + synthra.WithFile("overrides.yaml"), + synthra.WithEnv("DEMO_"), + ) + + if err := cfg.Load(context.Background()); err != nil { + log.Fatalf("load: %v", err) + } + + name, err := cfg.String("server.name") + if err != nil { + log.Fatalf("server.name: %v", err) + } + port, err := cfg.Int("server.port") + if err != nil { + log.Fatalf("server.port: %v", err) + } + fmt.Printf("server.name=%s server.port=%d\n", name, port) +} diff --git a/examples/defaults/main_test.go b/examples/defaults/main_test.go new file mode 100644 index 0000000..0ae04b0 --- /dev/null +++ b/examples/defaults/main_test.go @@ -0,0 +1,48 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "gopherly.dev/synthra" + "gopherly.dev/synthra/codec" +) + +func TestDefaultsMergeOrder(t *testing.T) { + t.Setenv("DEMO_SERVER_PORT", "9999") + + defaults := []byte(` +server: + port: 3000 + name: "defaults-only" +`) + + cfg := synthra.MustNew( + synthra.WithContent(defaults, codec.YAML), + synthra.WithFile("overrides.yaml"), + synthra.WithEnv("DEMO_"), + ) + require.NoError(t, cfg.Load(context.Background())) + + name, err := cfg.String("server.name") + require.NoError(t, err) + require.Equal(t, "from-file", name) + port, err := cfg.Int("server.port") + require.NoError(t, err) + require.Equal(t, 9999, port) +} diff --git a/examples/defaults/overrides.yaml b/examples/defaults/overrides.yaml new file mode 100644 index 0000000..64ae5bd --- /dev/null +++ b/examples/defaults/overrides.yaml @@ -0,0 +1,3 @@ +server: + port: 7000 + name: "from-file" diff --git a/examples/dump/README.md b/examples/dump/README.md new file mode 100644 index 0000000..2c188cf --- /dev/null +++ b/examples/dump/README.md @@ -0,0 +1,31 @@ +# Dump merged configuration + +Load from a YAML file and `APP_*` environment variables, then write the combined result to a new YAML file with `WithFileDumperAs` and `Dump`. + +This is handy for debugging -- you can see exactly what Synthra resolved after merging all sources. + +## Run + +```bash +cd examples/dump +APP_SERVER_PORT=9090 go run . +cat effective-config.yaml +``` + +You can also pass a custom output path: + +```bash +go run . /tmp/my-config.yaml +``` + +## Tests + +```bash +cd examples/dump && go test -v +``` + +## Key ideas + +1. **Inspect the merged state** -- `Dump` writes whatever `Load` produced. +2. **Any codec** -- `WithFileDumperAs` accepts `codec.YAML`, `codec.JSON`, or `codec.TOML`. +3. **No side effects on Load** -- the dumper only writes when you call `Dump` explicitly. diff --git a/examples/dump/config.yaml b/examples/dump/config.yaml new file mode 100644 index 0000000..558ff50 --- /dev/null +++ b/examples/dump/config.yaml @@ -0,0 +1,3 @@ +server: + host: "127.0.0.1" + port: 4000 diff --git a/examples/dump/main.go b/examples/dump/main.go new file mode 100644 index 0000000..f2e02d1 --- /dev/null +++ b/examples/dump/main.go @@ -0,0 +1,47 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package main writes the merged effective configuration to a YAML file. +package main + +import ( + "context" + "fmt" + "log" + "os" + + "gopherly.dev/synthra" + "gopherly.dev/synthra/codec" +) + +func main() { + out := "effective-config.yaml" + if len(os.Args) > 1 { + out = os.Args[1] + } + + cfg := synthra.MustNew( + synthra.WithFile("config.yaml"), + synthra.WithEnv("APP_"), + synthra.WithFileDumperAs(out, codec.YAML), + ) + + if err := cfg.Load(context.Background()); err != nil { + log.Fatalf("load: %v", err) + } + if err := cfg.Dump(context.Background()); err != nil { + log.Fatalf("dump: %v", err) + } + fmt.Printf("Wrote merged configuration to %s\n", out) +} diff --git a/examples/dump/main_test.go b/examples/dump/main_test.go new file mode 100644 index 0000000..79b8211 --- /dev/null +++ b/examples/dump/main_test.go @@ -0,0 +1,44 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "gopherly.dev/synthra" + "gopherly.dev/synthra/codec" +) + +func TestDump_WritesMergedYAML(t *testing.T) { + t.Setenv("APP_SERVER_PORT", "9090") + + out := filepath.Join(t.TempDir(), "out.yaml") + cfg := synthra.MustNew( + synthra.WithFile("config.yaml"), + synthra.WithEnv("APP_"), + synthra.WithFileDumperAs(out, codec.YAML), + ) + require.NoError(t, cfg.Load(context.Background())) + require.NoError(t, cfg.Dump(context.Background())) + + raw, err := os.ReadFile(out) // #nosec G304 -- path is t.TempDir() output from this test + require.NoError(t, err) + require.Contains(t, strings.ToLower(string(raw)), "9090") +} diff --git a/examples/environment/README.md b/examples/environment/README.md new file mode 100644 index 0000000..1ea2529 --- /dev/null +++ b/examples/environment/README.md @@ -0,0 +1,79 @@ +# Environment variables example + +Load all configuration from `WEBAPP_*` environment variables -- no files needed. + +## What it shows + +- `WithEnv("WEBAPP_")` as the only source +- Nested structs populated from underscore-separated variable names +- Direct key access with `cfg.String()`, `cfg.Int()`, `cfg.Bool()` +- Automatic string-to-type conversion + +## Set the variables + +```bash +export WEBAPP_SERVER_HOST=localhost +export WEBAPP_SERVER_PORT=8080 +export WEBAPP_DATABASE_PRIMARY_HOST=db.example.com +export WEBAPP_DATABASE_PRIMARY_PORT=5432 +export WEBAPP_DATABASE_PRIMARY_DATABASE=myapp +export WEBAPP_AUTH_JWT_SECRET=your-secret-key +export WEBAPP_FEATURES_DEBUG_MODE=true +``` + +## Run + +```bash +cd examples/environment && go run . +``` + +## Tests + +```bash +cd examples/environment && go test -v +``` + +## Expected output + +```text +=== Simple Configuration === +Server: localhost:8080 +Database: db.example.com:5432/myapp +Auth JWT Secret: your-secret-key +Debug Mode: true +============================ + +=== Direct Configuration Access === +Server: localhost:8080 +Database: db.example.com +Debug mode is enabled +``` + +## How variable names map to keys + +Strip the prefix, split on `_`, and lowercase each part. + +| Environment variable | Config path | Struct field | +|--------------------------------|-------------------------|-------------------------| +| `WEBAPP_SERVER_HOST` | `server.host` | `Server.Host` | +| `WEBAPP_DATABASE_PRIMARY_HOST` | `database.primary.host` | `Database.Primary.Host` | +| `WEBAPP_AUTH_JWT_SECRET` | `auth.jwt.secret` | `Auth.JWT.Secret` | + +## Key ideas + +1. **No files required** -- environment variables alone are enough. +2. **Underscore nesting** -- each `_` after the prefix creates a deeper level. +3. **Type conversion** -- string values are converted to the struct field's Go type. +4. **Direct access** -- you can also read keys with `cfg.String("server.host")` instead of binding. + +## Docker example + +This pattern follows the [Twelve-Factor App](https://12factor.net/config) methodology and works well with containers: + +```bash +docker run -e WEBAPP_SERVER_HOST=0.0.0.0 \ + -e WEBAPP_SERVER_PORT=8080 \ + -e WEBAPP_DATABASE_PRIMARY_HOST=prod-db \ + -e WEBAPP_AUTH_JWT_SECRET=prod-secret \ + your-app +``` diff --git a/examples/environment/main.go b/examples/environment/main.go new file mode 100644 index 0000000..883b59a --- /dev/null +++ b/examples/environment/main.go @@ -0,0 +1,129 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package main demonstrates loading configuration from environment +// variables with Synthra. +package main + +import ( + "context" + "fmt" + "log" + + "gopherly.dev/synthra" +) + +// SimpleConfig represents a simple configuration without validation +type SimpleConfig struct { + Server ServerConfig `synthra:"server"` + Database DatabaseConfig `synthra:"database"` + Auth AuthConfig `synthra:"auth"` + Features FeaturesConfig `synthra:"features"` +} + +// ServerConfig represents server configuration settings +type ServerConfig struct { + Host string `synthra:"host"` + Port int `synthra:"port"` +} + +// DatabaseConfig represents database configuration settings +type DatabaseConfig struct { + Primary PrimaryConfig `synthra:"primary"` +} + +// PrimaryConfig represents primary database connection settings +type PrimaryConfig struct { + Host string `synthra:"host"` + Port int `synthra:"port"` + Database string `synthra:"database"` +} + +// AuthConfig represents authentication configuration settings +type AuthConfig struct { + JWT JWTConfig `synthra:"jwt"` +} + +// JWTConfig represents JWT authentication settings +type JWTConfig struct { + Secret string `synthra:"secret"` +} + +// FeaturesConfig represents feature flags and settings +type FeaturesConfig struct { + Debug DebugConfig `synthra:"debug"` +} + +// DebugConfig represents debug mode settings +type DebugConfig struct { + Mode bool `synthra:"mode"` +} + +// PrintConfig displays the configuration in a readable format +func (c *SimpleConfig) PrintConfig() { + fmt.Println("=== Simple Configuration ===") + fmt.Printf("Server: %s:%d\n", c.Server.Host, c.Server.Port) + fmt.Printf("Database: %s:%d/%s\n", c.Database.Primary.Host, c.Database.Primary.Port, c.Database.Primary.Database) + fmt.Printf("Auth JWT Secret: %s\n", c.Auth.JWT.Secret) + fmt.Printf("Debug Mode: %t\n", c.Features.Debug.Mode) + fmt.Println("============================") +} + +func main() { + var sc SimpleConfig + + // Create configuration with environment variable source + cfg := synthra.MustNew( + synthra.WithEnv("WEBAPP_"), + synthra.WithBinding(&sc), + ) + + // Load configuration + if err := cfg.Load(context.Background()); err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + // Print the loaded configuration + sc.PrintConfig() + + // Demonstrate accessing configuration values directly + fmt.Println("\n=== Direct Configuration Access ===") + serverHost, err := cfg.String("server.host") + if err != nil { + log.Fatalf("server.host: %v", err) + } + serverPort, err := cfg.Int("server.port") + if err != nil { + log.Fatalf("server.port: %v", err) + } + databaseHost, err := cfg.String("database.primary.host") + if err != nil { + log.Fatalf("database.primary.host: %v", err) + } + + fmt.Printf("Server: %s:%d\n", serverHost, serverPort) + fmt.Printf("Database: %s\n", databaseHost) + + // Check if debug mode is enabled + debugMode, err := cfg.Bool("features.debug.mode") + if err != nil { + log.Fatalf("features.debug.mode: %v", err) + } + if debugMode { + fmt.Println("Debug mode is enabled") + } else { + fmt.Println("Debug mode is disabled") + } +} diff --git a/examples/environment/main_test.go b/examples/environment/main_test.go new file mode 100644 index 0000000..e0f3dba --- /dev/null +++ b/examples/environment/main_test.go @@ -0,0 +1,46 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "gopherly.dev/synthra" +) + +func TestEnvironment_OnlyEnvSource(t *testing.T) { + t.Setenv("WEBAPP_SERVER_HOST", "localhost") + t.Setenv("WEBAPP_SERVER_PORT", "8080") + t.Setenv("WEBAPP_DATABASE_PRIMARY_HOST", "db.example.com") + t.Setenv("WEBAPP_DATABASE_PRIMARY_PORT", "5432") + t.Setenv("WEBAPP_DATABASE_PRIMARY_DATABASE", "myapp") + t.Setenv("WEBAPP_AUTH_JWT_SECRET", "secret") + t.Setenv("WEBAPP_FEATURES_DEBUG_MODE", "true") + + var sc SimpleConfig + cfg := synthra.MustNew( + synthra.WithEnv("WEBAPP_"), + synthra.WithBinding(&sc), + ) + require.NoError(t, cfg.Load(context.Background())) + + require.Equal(t, "localhost", sc.Server.Host) + require.Equal(t, 8080, sc.Server.Port) + require.Equal(t, "db.example.com", sc.Database.Primary.Host) + require.True(t, sc.Features.Debug.Mode) +} diff --git a/examples/formats/README.md b/examples/formats/README.md new file mode 100644 index 0000000..c26cf1a --- /dev/null +++ b/examples/formats/README.md @@ -0,0 +1,26 @@ +# Explicit formats (`WithFileAs`) + +Load `app.json` as JSON, then merge `overrides.toml` as TOML. When you pass the codec explicitly with `WithFileAs`, the file extension does not need to match. + +## Run + +```bash +cd examples/formats && go run . +``` + +Expected output: `app=formats-demo listen.port=4000 meta.region=local` + +- `listen.port` starts as `3000` in the JSON file and is overridden to `4000` by the TOML file. +- `meta.region` only exists in the TOML file and is added to the merged result. + +## Tests + +```bash +cd examples/formats && go test -v +``` + +## Key ideas + +1. **Mix formats freely** -- JSON, TOML, and YAML can all be combined in the same config. +2. **Explicit codec** -- `WithFileAs` tells Synthra how to decode the file instead of guessing from the extension. +3. **Same merge rules** -- later sources override earlier ones, regardless of format. diff --git a/examples/formats/app.json b/examples/formats/app.json new file mode 100644 index 0000000..a4d6479 --- /dev/null +++ b/examples/formats/app.json @@ -0,0 +1,6 @@ +{ + "app": "formats-demo", + "listen": { + "port": 3000 + } +} diff --git a/examples/formats/main.go b/examples/formats/main.go new file mode 100644 index 0000000..5bdba11 --- /dev/null +++ b/examples/formats/main.go @@ -0,0 +1,50 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package main loads JSON and TOML with explicit codecs via WithFileAs. +package main + +import ( + "context" + "fmt" + "log" + + "gopherly.dev/synthra" + "gopherly.dev/synthra/codec" +) + +func main() { + cfg := synthra.MustNew( + synthra.WithFileAs("app.json", codec.JSON), + synthra.WithFileAs("overrides.toml", codec.TOML), + ) + + if err := cfg.Load(context.Background()); err != nil { + log.Fatalf("load: %v", err) + } + + app, err := cfg.String("app") + if err != nil { + log.Fatalf("app: %v", err) + } + listenPort, err := cfg.Int("listen.port") + if err != nil { + log.Fatalf("listen.port: %v", err) + } + region, err := cfg.String("meta.region") + if err != nil { + log.Fatalf("meta.region: %v", err) + } + fmt.Printf("app=%s listen.port=%d meta.region=%s\n", app, listenPort, region) +} diff --git a/examples/formats/main_test.go b/examples/formats/main_test.go new file mode 100644 index 0000000..49d569d --- /dev/null +++ b/examples/formats/main_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "gopherly.dev/synthra" + "gopherly.dev/synthra/codec" +) + +func TestFormats_MergeJSONThenTOML(t *testing.T) { + cfg := synthra.MustNew( + synthra.WithFileAs("app.json", codec.JSON), + synthra.WithFileAs("overrides.toml", codec.TOML), + ) + require.NoError(t, cfg.Load(context.Background())) + + app, err := cfg.String("app") + require.NoError(t, err) + require.Equal(t, "formats-demo", app) + listenPort, err := cfg.Int("listen.port") + require.NoError(t, err) + require.Equal(t, 4000, listenPort) + region, err := cfg.String("meta.region") + require.NoError(t, err) + require.Equal(t, "local", region) +} diff --git a/examples/formats/overrides.toml b/examples/formats/overrides.toml new file mode 100644 index 0000000..2523a02 --- /dev/null +++ b/examples/formats/overrides.toml @@ -0,0 +1,5 @@ +[listen] +port = 4000 + +[meta] +region = "local" diff --git a/examples/jsonschema/README.md b/examples/jsonschema/README.md new file mode 100644 index 0000000..1c73842 --- /dev/null +++ b/examples/jsonschema/README.md @@ -0,0 +1,53 @@ +# JSON Schema validation + +Validate the merged configuration against a JSON Schema before your program uses it. If a value has the wrong type or a required key is missing, `Load` returns an error right away. + +## What it shows + +- `WithJSONSchema(schema)` applied to a file source +- A valid config that passes the schema +- An invalid config (`config-invalid.yaml`) that is rejected at load time + +## The schema + +`schema.json` requires three keys: `service` (non-empty string), `port` (integer 1--65535), and `log_level` (one of `debug`, `info`, `warn`, `error`). + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["service", "port", "log_level"], + "properties": { + "service": { "type": "string", "minLength": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "log_level": { "type": "string", "enum": ["debug", "info", "warn", "error"] } + }, + "additionalProperties": true +} +``` + +## Run + +```bash +cd examples/jsonschema && go run . +``` + +Output: `service=api port=8080 log_level=info` + +## Try the invalid config + +`config-invalid.yaml` sets `port` to the string `"not-a-number"`, which violates the schema. You can test this in the test suite -- the load fails with a schema error. + +## Tests + +```bash +cd examples/jsonschema && go test -v +``` + +The tests cover both the happy path and the rejection path. + +## Key ideas + +1. **Fail early** -- schema errors surface during `Load`, not later in your application. +2. **Works with any source** -- the schema validates the merged map, so it works with files, env vars, or both. +3. **Standard format** -- the schema follows [JSON Schema 2020-12](https://json-schema.org/draft/2020-12/schema), so you can reuse it in editors, CI, or other tools. diff --git a/examples/jsonschema/config-invalid.yaml b/examples/jsonschema/config-invalid.yaml new file mode 100644 index 0000000..d5c9e79 --- /dev/null +++ b/examples/jsonschema/config-invalid.yaml @@ -0,0 +1,3 @@ +service: api +port: "not-a-number" +log_level: info diff --git a/examples/jsonschema/config.yaml b/examples/jsonschema/config.yaml new file mode 100644 index 0000000..9b0f69e --- /dev/null +++ b/examples/jsonschema/config.yaml @@ -0,0 +1,3 @@ +service: api +port: 8080 +log_level: info diff --git a/examples/jsonschema/main.go b/examples/jsonschema/main.go new file mode 100644 index 0000000..b4c8f79 --- /dev/null +++ b/examples/jsonschema/main.go @@ -0,0 +1,58 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package main demonstrates JSON Schema validation on loaded configuration. +package main + +import ( + "context" + "fmt" + "log" + "os" + + "gopherly.dev/synthra" +) + +func main() { + schema, err := os.ReadFile("schema.json") + if err != nil { + log.Fatalf("read schema: %v", err) + } + + cfg, err := synthra.New( + synthra.WithFile("config.yaml"), + synthra.WithJSONSchema(schema), + ) + if err != nil { + log.Fatalf("new: %v", err) + } + + if err = cfg.Load(context.Background()); err != nil { + log.Fatalf("load: %v", err) + } + + svc, err := cfg.String("service") + if err != nil { + log.Fatalf("service: %v", err) + } + port, err := cfg.Int("port") + if err != nil { + log.Fatalf("port: %v", err) + } + level, err := cfg.String("log_level") + if err != nil { + log.Fatalf("log_level: %v", err) + } + fmt.Printf("service=%s port=%d log_level=%s\n", svc, port, level) +} diff --git a/examples/jsonschema/main_test.go b/examples/jsonschema/main_test.go new file mode 100644 index 0000000..5a8c7c7 --- /dev/null +++ b/examples/jsonschema/main_test.go @@ -0,0 +1,48 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/require" + "gopherly.dev/synthra" +) + +func TestJSONSchema_ValidConfig(t *testing.T) { + schema, err := os.ReadFile("schema.json") + require.NoError(t, err) + + cfg, err := synthra.New( + synthra.WithFile("config.yaml"), + synthra.WithJSONSchema(schema), + ) + require.NoError(t, err) + require.NoError(t, cfg.Load(context.Background())) +} + +func TestJSONSchema_InvalidConfigRejected(t *testing.T) { + schema, err := os.ReadFile("schema.json") + require.NoError(t, err) + + cfg, err := synthra.New( + synthra.WithFile("config-invalid.yaml"), + synthra.WithJSONSchema(schema), + ) + require.NoError(t, err) + require.Error(t, cfg.Load(context.Background())) +} diff --git a/examples/jsonschema/schema.json b/examples/jsonschema/schema.json new file mode 100644 index 0000000..a8d43dd --- /dev/null +++ b/examples/jsonschema/schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["service", "port", "log_level"], + "properties": { + "service": { "type": "string", "minLength": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "log_level": { "type": "string", "enum": ["debug", "info", "warn", "error"] } + }, + "additionalProperties": true +} diff --git a/examples/testing/README.md b/examples/testing/README.md new file mode 100644 index 0000000..919d968 --- /dev/null +++ b/examples/testing/README.md @@ -0,0 +1,29 @@ +# Testing helpers + +Build configuration in tests without touching the filesystem. Use [`source.NewMap`](https://pkg.go.dev/gopherly.dev/synthra/source#NewMap) to provide values from a plain Go map, and [`synthratest.Config`](https://pkg.go.dev/gopherly.dev/synthra/synthratest#Config) to get a ready-to-use `*synthra.Config` that fails the test on construction errors. + +## Run the tests + +```bash +cd examples/testing && go test -v +``` + +`go run .` prints a short pointer to the tests -- the real content is in `main_test.go`. + +## Example + +```go +cfg := synthratest.Config(t, + synthra.WithSource(source.NewMap(map[string]any{ + "server": map[string]any{"port": 8080, "host": "127.0.0.1"}, + })), +) +require.NoError(t, cfg.Load(t.Context())) +port, err := cfg.Int("server.port") +``` + +## Key ideas + +1. **No files in tests** -- `source.NewMap` keeps tests fast, deterministic, and free from path issues. +2. **Test helper** -- `synthratest.Config` calls `t.Fatal` on error so your test stays clean. +3. **Same API** -- the config object you get back works exactly like a production one. diff --git a/examples/testing/main.go b/examples/testing/main.go new file mode 100644 index 0000000..7052c5f --- /dev/null +++ b/examples/testing/main.go @@ -0,0 +1,23 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package main exists so `go run .` works; see README and *_test.go. +package main + +import "fmt" + +func main() { + fmt.Println("This directory demonstrates gopherly.dev/synthra/synthratest.Config and source.NewMap in tests.") + fmt.Println("Run: go test -v") +} diff --git a/examples/testing/main_test.go b/examples/testing/main_test.go new file mode 100644 index 0000000..f6d8047 --- /dev/null +++ b/examples/testing/main_test.go @@ -0,0 +1,39 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" + "gopherly.dev/synthra" + "gopherly.dev/synthra/source" + "gopherly.dev/synthra/synthratest" +) + +func TestSynthraTestConfig_LoadsMockSource(t *testing.T) { + cfg := synthratest.Config(t, + synthra.WithSource(source.NewMap(map[string]any{ + "server": map[string]any{"port": 8080, "host": "127.0.0.1"}, + })), + ) + require.NoError(t, cfg.Load(t.Context())) + port, err := cfg.Int("server.port") + require.NoError(t, err) + require.Equal(t, 8080, port) + host, err := cfg.String("server.host") + require.NoError(t, err) + require.Equal(t, "127.0.0.1", host) +} diff --git a/examples/webapp/README.md b/examples/webapp/README.md new file mode 100644 index 0000000..96dba9f --- /dev/null +++ b/examples/webapp/README.md @@ -0,0 +1,68 @@ +# Web application example (layered config) + +A realistic setup: **YAML defaults**, **`WEBAPP_*` environment overrides**, **struct binding**, and a **`Validate` method** on the bound struct. + +## What it shows + +- Layered sources -- `WithFile` first, then `WithEnv` (later source wins on conflicts) +- Deeply nested YAML matching nested struct tags (`server.read.timeout`, etc.) +- Direct key access with dot paths (`cfg.String("server.host")`) +- Struct-level validation via the [`synthra.Validator`](https://pkg.go.dev/gopherly.dev/synthra#Validator) interface +- Tests for env-only, YAML-only, layered precedence, and validation failures + +## Run + +```bash +cd examples/webapp && go run . +``` + +## Tests + +```bash +cd examples/webapp && go test -v +``` + +## Load keys from your shell (optional) + +```bash +source examples/webapp/setup_env.sh +cd examples/webapp && go run . +``` + +## How environment variables map to keys + +Strip the `WEBAPP_` prefix, split on `_`, lowercase. + +| Variable | Config key | +|----------|------------| +| `WEBAPP_SERVER_PORT` | `server.port` | +| `WEBAPP_SERVER_READ_TIMEOUT` | `server.read.timeout` | +| `WEBAPP_DATABASE_PRIMARY_HOST` | `database.primary.host` | +| `WEBAPP_FEATURES_DEBUG_MODE` | `features.debug.mode` | + +## Production-style construction + +```go +cfg, err := synthra.New( + synthra.WithFile("config.yaml"), + synthra.WithEnv("WEBAPP_"), + synthra.WithBinding(&appConfig), +) +if err != nil { + log.Fatal(err) +} +if err := cfg.Load(ctx); err != nil { + log.Fatal(err) +} +``` + +## Docker (optional) + +From the **repository root** (see [examples/Dockerfile](../Dockerfile)): + +```bash +docker build -f examples/Dockerfile -t synthra-webapp-example . +docker run --rm synthra-webapp-example +``` + +Override values at runtime with `-e WEBAPP_SERVER_PORT=8080`, and so on. diff --git a/examples/webapp/config.yaml b/examples/webapp/config.yaml new file mode 100644 index 0000000..8ff24f3 --- /dev/null +++ b/examples/webapp/config.yaml @@ -0,0 +1,76 @@ +# Web Application Configuration +# This file provides default values that can be overridden by environment variables + +server: + host: "localhost" + port: 3000 + read: + timeout: "30s" + write: + timeout: "30s" + tls: + enabled: false + cert: + file: "/etc/ssl/certs/server.crt" + key: + file: "/etc/ssl/private/server.key" + +database: + primary: + host: "localhost" + port: 5432 + database: "myapp_dev" + username: "postgres" + password: "dev_password" + ssl: + mode: "disable" + replica: + host: "localhost" + port: 5432 + database: "myapp_dev" + username: "readonly" + password: "readonly_password" + ssl: + mode: "disable" + pool: + max: + open: 10 + idle: 5 + lifetime: "5m" + +redis: + host: "localhost" + port: 6379 + password: "" + database: 0 + timeout: "5s" + +auth: + jwt: + secret: "dev-jwt-secret-change-in-production" + token: + duration: "24h" + refresh: + secret: "dev-refresh-secret-change-in-production" + +logging: + level: "debug" + format: "text" + output: + file: "" + +monitoring: + enabled: false + metrics: + port: 9090 + health: + path: "/health" + +features: + rate: + limit: + enabled: false + cache: + enabled: false + debug: + mode: true \ No newline at end of file diff --git a/examples/webapp/layered_test.go b/examples/webapp/layered_test.go new file mode 100644 index 0000000..d212ebd --- /dev/null +++ b/examples/webapp/layered_test.go @@ -0,0 +1,104 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopherly.dev/synthra" + "gopherly.dev/synthra/synthratest" +) + +func TestLayeredYAMLAndEnvironmentVariables(t *testing.T) { + t.Setenv("WEBAPP_SERVER_PORT", "9090") + t.Setenv("WEBAPP_DATABASE_PRIMARY_HOST", "test-db") + t.Setenv("WEBAPP_AUTH_JWT_SECRET", "test-secret") + t.Setenv("WEBAPP_FEATURES_DEBUG_MODE", "false") + + cfg, err := synthra.New( + synthra.WithFile("config.yaml"), + synthra.WithEnv("WEBAPP_"), + ) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.NoError(t, err) + + synthratest.AssertString(t, cfg, "server.host", "localhost") + synthratest.AssertInt(t, cfg, "server.port", 9090) + synthratest.AssertString(t, cfg, "database.primary.host", "test-db") + synthratest.AssertInt(t, cfg, "database.primary.port", 5432) + synthratest.AssertString(t, cfg, "auth.jwt.secret", "test-secret") + synthratest.AssertBool(t, cfg, "features.debug.mode", false) + + var wc WebAppConfig + cfgWithBinding, err := synthra.New( + synthra.WithFile("config.yaml"), + synthra.WithEnv("WEBAPP_"), + synthra.WithBinding(&wc), + ) + require.NoError(t, err) + + err = cfgWithBinding.Load(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "localhost", wc.Server.Host) + assert.Equal(t, 9090, wc.Server.Port) + assert.Equal(t, "test-db", wc.Database.Primary.Host) + assert.Equal(t, 5432, wc.Database.Primary.Port) + assert.Equal(t, "test-secret", wc.Auth.JWT.Secret) + assert.False(t, wc.Features.Debug.Mode) +} + +func TestYAMLOnlyConfiguration(t *testing.T) { + cfg, err := synthra.New( + synthra.WithFile("config.yaml"), + ) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.NoError(t, err) + + synthratest.AssertString(t, cfg, "server.host", "localhost") + synthratest.AssertInt(t, cfg, "server.port", 3000) + synthratest.AssertString(t, cfg, "database.primary.host", "localhost") + synthratest.AssertInt(t, cfg, "database.primary.port", 5432) + synthratest.AssertString(t, cfg, "auth.jwt.secret", "dev-jwt-secret-change-in-production") + synthratest.AssertBool(t, cfg, "features.debug.mode", true) +} + +func TestEnvironmentVariablesOnly(t *testing.T) { + t.Setenv("WEBAPP_SERVER_HOST", "env-host") + t.Setenv("WEBAPP_SERVER_PORT", "8080") + t.Setenv("WEBAPP_DATABASE_PRIMARY_HOST", "env-db") + t.Setenv("WEBAPP_AUTH_JWT_SECRET", "env-secret") + + cfg, err := synthra.New( + synthra.WithEnv("WEBAPP_"), + ) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.NoError(t, err) + + synthratest.AssertString(t, cfg, "server.host", "env-host") + synthratest.AssertInt(t, cfg, "server.port", 8080) + synthratest.AssertString(t, cfg, "database.primary.host", "env-db") + synthratest.AssertString(t, cfg, "auth.jwt.secret", "env-secret") +} diff --git a/examples/webapp/main.go b/examples/webapp/main.go new file mode 100644 index 0000000..fe41425 --- /dev/null +++ b/examples/webapp/main.go @@ -0,0 +1,295 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package main demonstrates layered configuration (YAML defaults plus +// environment overrides), struct binding, and validation with Synthra. +package main + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "gopherly.dev/synthra" +) + +// WebAppConfig represents a complete web application configuration +// that can be populated from environment variables +type WebAppConfig struct { + Server ServerConfig `synthra:"server"` + Database DatabaseConfig `synthra:"database"` + Redis RedisConfig `synthra:"redis"` + Auth AuthConfig `synthra:"auth"` + Logging LoggingConfig `synthra:"logging"` + Monitoring MonitoringConfig `synthra:"monitoring"` + Features FeaturesConfig `synthra:"features"` +} + +// ServerConfig represents server configuration settings +type ServerConfig struct { + Host string `synthra:"host"` + Port int `synthra:"port"` + ReadTimeout time.Duration `synthra:"read.timeout"` + WriteTimeout time.Duration `synthra:"write.timeout"` + TLS TLSConfig `synthra:"tls"` +} + +// TLSConfig represents TLS/SSL configuration settings +type TLSConfig struct { + Enabled bool `synthra:"enabled"` + Cert CertConfig `synthra:"cert"` + Key KeyConfig `synthra:"key"` +} + +// CertConfig represents TLS certificate configuration +type CertConfig struct { + File string `synthra:"file"` +} + +// KeyConfig represents TLS private key configuration +type KeyConfig struct { + File string `synthra:"file"` +} + +// DatabaseConfig represents database configuration settings +type DatabaseConfig struct { + Primary PrimaryConfig `synthra:"primary"` + Replica ReplicaConfig `synthra:"replica"` + Pool PoolConfig `synthra:"pool"` +} + +// PrimaryConfig represents primary database connection settings +type PrimaryConfig struct { + Host string `synthra:"host"` + Port int `synthra:"port"` + Database string `synthra:"database"` + Username string `synthra:"username"` + Password string `synthra:"password"` + SSLMode string `synthra:"ssl.mode"` +} + +// ReplicaConfig represents replica database connection settings +type ReplicaConfig struct { + Host string `synthra:"host"` + Port int `synthra:"port"` + Database string `synthra:"database"` + Username string `synthra:"username"` + Password string `synthra:"password"` + SSLMode string `synthra:"ssl.mode"` +} + +// PoolConfig represents database connection pool settings +type PoolConfig struct { + Max MaxConfig `synthra:"max"` +} + +// MaxConfig represents maximum connection pool limits +type MaxConfig struct { + Open int `synthra:"open"` + Idle int `synthra:"idle"` + Lifetime time.Duration `synthra:"lifetime"` +} + +// RedisConfig represents Redis connection settings +type RedisConfig struct { + Host string `synthra:"host"` + Port int `synthra:"port"` + Password string `synthra:"password"` + Database int `synthra:"database"` + Timeout time.Duration `synthra:"timeout"` +} + +// AuthConfig represents authentication configuration settings +type AuthConfig struct { + JWT JWTConfig `synthra:"jwt"` + Token TokenConfig `synthra:"token"` + Refresh RefreshConfig `synthra:"refresh"` +} + +// JWTConfig represents JWT authentication settings +type JWTConfig struct { + Secret string `synthra:"secret"` +} + +// TokenConfig represents token configuration settings +type TokenConfig struct { + Duration time.Duration `synthra:"duration"` +} + +// RefreshConfig represents refresh token configuration settings +type RefreshConfig struct { + Secret string `synthra:"secret"` +} + +// LoggingConfig represents logging configuration settings +type LoggingConfig struct { + Level string `synthra:"level"` + Format string `synthra:"format"` + OutputFile string `synthra:"output.file"` +} + +// MonitoringConfig represents monitoring and metrics configuration +type MonitoringConfig struct { + Enabled bool `synthra:"enabled"` + MetricsPort int `synthra:"metrics.port"` + HealthPath string `synthra:"health.path"` +} + +// FeaturesConfig represents feature flags and settings +type FeaturesConfig struct { + RateLimit RateLimitConfig `synthra:"rate.limit"` + Cache CacheConfig `synthra:"cache"` + Debug DebugConfig `synthra:"debug"` +} + +// RateLimitConfig represents rate limiting configuration +type RateLimitConfig struct { + Enabled bool `synthra:"enabled"` +} + +// CacheConfig represents caching configuration +type CacheConfig struct { + Enabled bool `synthra:"enabled"` +} + +// DebugConfig represents debug mode settings +type DebugConfig struct { + Mode bool `synthra:"mode"` +} + +// PrintConfig displays the configuration in a readable format +func (c *WebAppConfig) PrintConfig() { + fmt.Println("=== Web Application Configuration (YAML + Environment Variables) ===") + fmt.Printf("Server: %s:%d\n", c.Server.Host, c.Server.Port) + fmt.Printf(" Read Timeout: %v\n", c.Server.ReadTimeout) + fmt.Printf(" Write Timeout: %v\n", c.Server.WriteTimeout) + fmt.Printf(" TLS Enabled: %t\n", c.Server.TLS.Enabled) + if c.Server.TLS.Enabled { + fmt.Printf(" TLS Cert: %s\n", c.Server.TLS.Cert.File) + fmt.Printf(" TLS Key: %s\n", c.Server.TLS.Key.File) + } + + fmt.Printf("\nDatabase Primary: %s:%d/%s\n", + c.Database.Primary.Host, c.Database.Primary.Port, c.Database.Primary.Database) + fmt.Printf("Database Replica: %s:%d/%s\n", + c.Database.Replica.Host, c.Database.Replica.Port, c.Database.Replica.Database) + fmt.Printf("Database Pool: MaxOpen=%d, MaxIdle=%d, MaxLifetime=%v\n", + c.Database.Pool.Max.Open, c.Database.Pool.Max.Idle, c.Database.Pool.Max.Lifetime) + + fmt.Printf("\nRedis: %s:%d (DB: %d)\n", c.Redis.Host, c.Redis.Port, c.Redis.Database) + fmt.Printf("Redis Timeout: %v\n", c.Redis.Timeout) + + fmt.Printf("\nAuth Token Duration: %v\n", c.Auth.Token.Duration) + fmt.Printf("Logging Level: %s, Format: %s\n", c.Logging.Level, c.Logging.Format) + if c.Logging.OutputFile != "" { + fmt.Printf("Logging Output: %s\n", c.Logging.OutputFile) + } + + fmt.Printf("\nMonitoring Enabled: %t\n", c.Monitoring.Enabled) + if c.Monitoring.Enabled { + fmt.Printf("Metrics Port: %d\n", c.Monitoring.MetricsPort) + fmt.Printf("Health Path: %s\n", c.Monitoring.HealthPath) + } + + fmt.Printf("\nFeatures:\n") + fmt.Printf(" Rate Limit: %t\n", c.Features.RateLimit.Enabled) + fmt.Printf(" Cache: %t\n", c.Features.Cache.Enabled) + fmt.Printf(" Debug Mode: %t\n", c.Features.Debug.Mode) + fmt.Println("=====================================") +} + +// Validate implements [synthra.Validator] for startup checks on the bound struct. +func (c *WebAppConfig) Validate() error { + if c.Server.Port < 1 || c.Server.Port > 65535 { + return fmt.Errorf("server.port must be between 1 and 65535, got %d", c.Server.Port) + } + if c.Auth.JWT.Secret == "" { + return errors.New("auth.jwt.secret is required") + } + if c.Server.TLS.Enabled { + if c.Server.TLS.Cert.File == "" { + return errors.New("server.tls.cert.file is required when TLS is enabled") + } + if c.Server.TLS.Key.File == "" { + return errors.New("server.tls.key.file is required when TLS is enabled") + } + } + return nil +} + +func main() { + var wc WebAppConfig + + // Create configuration with multiple sources + cfg := synthra.MustNew( + // First, load from YAML file (default values) + synthra.WithFile("config.yaml"), + // Then, override with environment variables (higher precedence) + synthra.WithEnv("WEBAPP_"), + // Bind to our struct + synthra.WithBinding(&wc), + ) + + // Load configuration + if err := cfg.Load(context.Background()); err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + // Print the loaded configuration + wc.PrintConfig() + + // Demonstrate accessing configuration values directly + fmt.Println("\n=== Direct Configuration Access ===") + serverHost, err := cfg.String("server.host") + if err != nil { + log.Fatalf("server.host: %v", err) + } + serverPort, err := cfg.Int("server.port") + if err != nil { + log.Fatalf("server.port: %v", err) + } + databaseHost, err := cfg.String("database.primary.host") + if err != nil { + log.Fatalf("database.primary.host: %v", err) + } + + fmt.Printf("Server: %s:%d\n", serverHost, serverPort) + fmt.Printf("Database: %s\n", databaseHost) + + // Check if TLS is enabled + tlsEnabled, err := cfg.Bool("server.tls.enabled") + if err != nil { + log.Fatalf("server.tls.enabled: %v", err) + } + if tlsEnabled { + fmt.Println("TLS is enabled") + } else { + fmt.Println("TLS is disabled") + } + + // Demonstrate configuration precedence + fmt.Println("\n=== Configuration Precedence Demo ===") + fmt.Println("Values are loaded in this order:") + fmt.Println("1. YAML file (config.yaml) - default values") + fmt.Println("2. Environment variables (WEBAPP_*) - override defaults") + fmt.Println("") + fmt.Println("Example: If YAML has server.port=3000 and env has WEBAPP_SERVER_PORT=8080") + fmt.Println("The final value will be 8080 (environment variable wins)") + fmt.Println("") + fmt.Println("Env keys: strip the prefix, split on underscores, nest (e.g.") + fmt.Println("WEBAPP_DATABASE_PRIMARY_HOST -> database.primary.host).") +} diff --git a/examples/webapp/main_test.go b/examples/webapp/main_test.go new file mode 100644 index 0000000..74a95f0 --- /dev/null +++ b/examples/webapp/main_test.go @@ -0,0 +1,171 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopherly.dev/synthra" + "gopherly.dev/synthra/synthratest" +) + +func TestWebAppConfig_EnvironmentVariables(t *testing.T) { + // Set up test environment variables (all required fields) + t.Setenv("WEBAPP_SERVER_HOST", "test-host") + t.Setenv("WEBAPP_SERVER_PORT", "9090") + t.Setenv("WEBAPP_DATABASE_PRIMARY_HOST", "test-db") + t.Setenv("WEBAPP_DATABASE_PRIMARY_PORT", "5432") + t.Setenv("WEBAPP_DATABASE_PRIMARY_DATABASE", "testdb") + t.Setenv("WEBAPP_AUTH_JWT_SECRET", "test-secret") + t.Setenv("WEBAPP_AUTH_TOKEN_DURATION", "1h") + t.Setenv("WEBAPP_FEATURES_DEBUG_MODE", "true") + + // Create configuration without binding to test direct access + cfg, err := synthra.New( + synthra.WithEnv("WEBAPP_"), + ) + require.NoError(t, err) + + // Load configuration + err = cfg.Load(context.Background()) + require.NoError(t, err) + + // Test direct configuration access + synthratest.AssertString(t, cfg, "server.host", "test-host") + synthratest.AssertInt(t, cfg, "server.port", 9090) + synthratest.AssertString(t, cfg, "database.primary.host", "test-db") + synthratest.AssertInt(t, cfg, "database.primary.port", 5432) + synthratest.AssertString(t, cfg, "database.primary.database", "testdb") + synthratest.AssertString(t, cfg, "auth.jwt.secret", "test-secret") + synthratest.AssertBool(t, cfg, "features.debug.mode", true) + + // Now test with binding + var wc WebAppConfig + cfgWithBinding, err := synthra.New( + synthra.WithEnv("WEBAPP_"), + synthra.WithBinding(&wc), + ) + require.NoError(t, err) + + // Load configuration with binding + err = cfgWithBinding.Load(context.Background()) + require.NoError(t, err) + + // Test struct binding + assert.Equal(t, "test-host", wc.Server.Host) + assert.Equal(t, 9090, wc.Server.Port) + assert.Equal(t, "test-db", wc.Database.Primary.Host) + assert.Equal(t, 5432, wc.Database.Primary.Port) + assert.Equal(t, "testdb", wc.Database.Primary.Database) + assert.Equal(t, "test-secret", wc.Auth.JWT.Secret) + assert.True(t, wc.Features.Debug.Mode) +} + +func TestWebAppConfig_NestedStructures(t *testing.T) { + // Test nested environment variable mapping (including required fields) + t.Setenv("WEBAPP_SERVER_HOST", "test-host") + t.Setenv("WEBAPP_SERVER_PORT", "9090") + t.Setenv("WEBAPP_DATABASE_PRIMARY_HOST", "test-db") + t.Setenv("WEBAPP_DATABASE_PRIMARY_PORT", "5432") + t.Setenv("WEBAPP_DATABASE_PRIMARY_DATABASE", "testdb") + t.Setenv("WEBAPP_AUTH_JWT_SECRET", "test-secret") + t.Setenv("WEBAPP_AUTH_TOKEN_DURATION", "1h") + t.Setenv("WEBAPP_SERVER_TLS_ENABLED", "true") + t.Setenv("WEBAPP_SERVER_TLS_CERT_FILE", "/path/to/cert.pem") + t.Setenv("WEBAPP_SERVER_TLS_KEY_FILE", "/path/to/key.pem") + t.Setenv("WEBAPP_DATABASE_POOL_MAX_OPEN", "50") + t.Setenv("WEBAPP_DATABASE_POOL_MAX_IDLE", "10") + + // Test direct access first + cfg, err := synthra.New( + synthra.WithEnv("WEBAPP_"), + ) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.NoError(t, err) + + // Test direct access to nested values + synthratest.AssertBool(t, cfg, "server.tls.enabled", true) + synthratest.AssertString(t, cfg, "server.tls.cert.file", "/path/to/cert.pem") + synthratest.AssertString(t, cfg, "server.tls.key.file", "/path/to/key.pem") + synthratest.AssertInt(t, cfg, "database.pool.max.open", 50) + synthratest.AssertInt(t, cfg, "database.pool.max.idle", 10) + + // Now test with binding + var wc WebAppConfig + cfgWithBinding, err := synthra.New( + synthra.WithEnv("WEBAPP_"), + synthra.WithBinding(&wc), + ) + require.NoError(t, err) + + err = cfgWithBinding.Load(context.Background()) + require.NoError(t, err) + + // Test nested TLS configuration + assert.True(t, wc.Server.TLS.Enabled) + assert.Equal(t, "/path/to/cert.pem", wc.Server.TLS.Cert.File) + assert.Equal(t, "/path/to/key.pem", wc.Server.TLS.Key.File) + + // Test nested database pool configuration + assert.Equal(t, 50, wc.Database.Pool.Max.Open) + assert.Equal(t, 10, wc.Database.Pool.Max.Idle) +} + +func TestWebAppConfig_Validate_InvalidPort(t *testing.T) { + t.Setenv("WEBAPP_SERVER_HOST", "localhost") + t.Setenv("WEBAPP_SERVER_PORT", "0") + t.Setenv("WEBAPP_DATABASE_PRIMARY_HOST", "db") + t.Setenv("WEBAPP_DATABASE_PRIMARY_PORT", "5432") + t.Setenv("WEBAPP_DATABASE_PRIMARY_DATABASE", "app") + t.Setenv("WEBAPP_AUTH_JWT_SECRET", "secret") + t.Setenv("WEBAPP_AUTH_TOKEN_DURATION", "1h") + + cfg, err := synthra.New( + synthra.WithFile("config.yaml"), + synthra.WithEnv("WEBAPP_"), + synthra.WithBinding(&WebAppConfig{}), + ) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.Error(t, err) +} + +func TestWebAppConfig_Validate_TLSRequiresCertFiles(t *testing.T) { + t.Setenv("WEBAPP_SERVER_HOST", "localhost") + t.Setenv("WEBAPP_SERVER_PORT", "8080") + t.Setenv("WEBAPP_SERVER_TLS_ENABLED", "true") + t.Setenv("WEBAPP_DATABASE_PRIMARY_HOST", "db") + t.Setenv("WEBAPP_DATABASE_PRIMARY_PORT", "5432") + t.Setenv("WEBAPP_DATABASE_PRIMARY_DATABASE", "app") + t.Setenv("WEBAPP_AUTH_JWT_SECRET", "secret") + t.Setenv("WEBAPP_AUTH_TOKEN_DURATION", "1h") + // Deliberately omit cert and key paths + + cfg, err := synthra.New( + synthra.WithEnv("WEBAPP_"), + synthra.WithBinding(&WebAppConfig{}), + ) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.Error(t, err) +} diff --git a/examples/webapp/setup_env.sh b/examples/webapp/setup_env.sh new file mode 100755 index 0000000..fdf1eea --- /dev/null +++ b/examples/webapp/setup_env.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +# Environment Variable Example Setup Script +# This script sets up all the environment variables needed to run the example + +echo "Setting up WEBAPP_* variables for the Synthra webapp example (source: examples/webapp/setup_env.sh)." + +# Server Configuration +export WEBAPP_SERVER_HOST=0.0.0.0 +export WEBAPP_SERVER_PORT=8080 +export WEBAPP_SERVER_READ_TIMEOUT=30s +export WEBAPP_SERVER_WRITE_TIMEOUT=30s +export WEBAPP_SERVER_TLS_ENABLED=false + +# Database Configuration +export WEBAPP_DATABASE_PRIMARY_HOST=localhost +export WEBAPP_DATABASE_PRIMARY_PORT=5432 +export WEBAPP_DATABASE_PRIMARY_DATABASE=myapp +export WEBAPP_DATABASE_PRIMARY_USERNAME=postgres +export WEBAPP_DATABASE_PRIMARY_PASSWORD=secret123 +export WEBAPP_DATABASE_PRIMARY_SSL_MODE=disable + +export WEBAPP_DATABASE_REPLICA_HOST=replica.example.com +export WEBAPP_DATABASE_REPLICA_PORT=5432 +export WEBAPP_DATABASE_REPLICA_DATABASE=myapp +export WEBAPP_DATABASE_REPLICA_USERNAME=readonly +export WEBAPP_DATABASE_REPLICA_PASSWORD=readonly123 +export WEBAPP_DATABASE_REPLICA_SSL_MODE=require + +export WEBAPP_DATABASE_POOL_MAX_OPEN=25 +export WEBAPP_DATABASE_POOL_MAX_IDLE=5 +export WEBAPP_DATABASE_POOL_MAX_LIFETIME=5m + +# Redis Configuration +export WEBAPP_REDIS_HOST=localhost +export WEBAPP_REDIS_PORT=6379 +export WEBAPP_REDIS_PASSWORD= +export WEBAPP_REDIS_DATABASE=0 +export WEBAPP_REDIS_TIMEOUT=5s + +# Authentication +export WEBAPP_AUTH_JWT_SECRET=your-super-secret-jwt-key-here +export WEBAPP_AUTH_TOKEN_DURATION=24h +export WEBAPP_AUTH_REFRESH_SECRET=your-refresh-secret-key + +# Logging +export WEBAPP_LOGGING_LEVEL=info +export WEBAPP_LOGGING_FORMAT=json +export WEBAPP_LOGGING_OUTPUT_FILE=/var/log/myapp.log + +# Monitoring +export WEBAPP_MONITORING_ENABLED=true +export WEBAPP_MONITORING_METRICS_PORT=9090 +export WEBAPP_MONITORING_HEALTH_PATH=/health + +# Features +export WEBAPP_FEATURES_RATE_LIMIT_ENABLED=true +export WEBAPP_FEATURES_CACHE_ENABLED=true +export WEBAPP_FEATURES_DEBUG_MODE=false + +echo "Environment variables set successfully!" +echo "" +echo "You can now run the example with:" +echo "go run main.go" +echo "" +echo "Or source this script in your current shell:" +echo "source setup_env.sh" +echo "" +echo "To see all set environment variables:" +echo "env | grep WEBAPP_" \ No newline at end of file diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..fdb0ad4 --- /dev/null +++ b/flake.lock @@ -0,0 +1,121 @@ +{ + "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "NixOS", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "git-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1776796298, + "narHash": "sha256-PcRvlWayisPSjd0UcRQbhG8Oqw78AcPE6x872cPRHN8=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "3cfd774b0a530725a077e17354fbdb87ea1c4aad", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1777954456, + "narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "git-hooks": "git-hooks", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..9026128 --- /dev/null +++ b/flake.nix @@ -0,0 +1,194 @@ +{ + description = "Synthra — Go configuration synthesis library (dev shell)"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + git-hooks = { + url = "github:cachix/git-hooks.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + git-hooks, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + + devTools = with pkgs; [ + go + gopls + gotools + golangci-lint + markdownlint-cli + delve + git + ]; + + mkApp = + { + name, + description, + script, + }: + { + type = "app"; + program = toString (pkgs.writeShellScript name script); + meta = { + mainProgram = name; + inherit description; + }; + }; + + mkTaggedRaceTest = + { + name, + description, + tags, + coverProfile, + # With -tags=integration only the module root has *_test.go; listing ./... still + # selects child packages with no tests and triggers Nix Go covdata failures under -coverpkg. + integrationTestsAtModuleRoot ? false, + }: + let + goListCmd = + if integrationTestsAtModuleRoot then + ''"$go" list -tags=${tags} .'' + else + ''"$go" list -tags=${tags} ./...''; + in + mkApp { + inherit name description; + script = '' + # Nix Go only (go#75031). Example mains under examples/ are not test packages; including + # them in one -coverpkg=./... run triggers "no such tool covdata" in CI. + export GOTOOLCHAIN=local + go="${pkgs.go}/bin/go" + mapfile -t testpkgs < <(${goListCmd} | grep -vE '/examples(/|$)' || true) + if [ ''${#testpkgs[@]} -eq 0 ]; then + echo "go list: no test packages after filters (tags=${tags})" >&2 + exit 1 + fi + exec "$go" test -tags=${tags} -race -shuffle=on -covermode=atomic \ + -coverpkg=./... -coverprofile=${coverProfile} -timeout 10m "''${testpkgs[@]}" + ''; + }; + + pre-commit-check = git-hooks.lib.${system}.run { + src = ./.; + hooks = { + gofmt.enable = true; + # git-hooks' default env omits `go` on PATH; golangci-lint needs it. + golangci-lint = { + enable = true; + extraPackages = [ pkgs.go ]; + }; + markdownlint = { + enable = true; + excludes = [ "node_modules" ]; + settings.configuration = builtins.fromJSON (builtins.readFile ./.markdownlint.json); + }; + go-mod-tidy = { + enable = true; + name = "go-mod-tidy"; + entry = "${pkgs.go}/bin/go mod tidy"; + files = "(\\.go|go\\.mod|go\\.sum)$"; + pass_filenames = false; + }; + nixfmt.enable = true; + }; + }; + in + { + formatter = pkgs.nixfmt-tree; + + checks = { + pre-commit = pre-commit-check; + }; + + devShells.default = pkgs.mkShell { + name = "synthra"; + packages = devTools ++ pre-commit-check.enabledPackages; + env = { + GO111MODULE = "on"; + CGO_ENABLED = "1"; + }; + shellHook = '' + ${pre-commit-check.shellHook} + export GOPATH="''${GOPATH:-$HOME/go}" + export PATH="$GOPATH/bin:$PATH" + echo "Synthra dev shell — $(go version)" + ''; + }; + + apps = { + fmt = mkApp { + name = "fmt"; + description = "Format all Go files with gofmt"; + script = '' + exec ${pkgs.go}/bin/gofmt -w . + ''; + }; + + fmt-check = mkApp { + name = "fmt-check"; + description = "Fail if any Go file needs gofmt (lists paths)"; + script = '' + out=$(${pkgs.go}/bin/gofmt -l .) + if [ -n "$out" ]; then + echo "::error::Unformatted Go files:" >&2 + echo "$out" >&2 + exit 1 + fi + ''; + }; + + tidy = mkApp { + name = "tidy"; + description = "Run go mod tidy for the module"; + script = '' + exec ${pkgs.go}/bin/go mod tidy + ''; + }; + + lint = mkApp { + name = "lint"; + description = "Run golangci-lint"; + script = '' + exec ${pkgs.golangci-lint}/bin/golangci-lint run ./... + ''; + }; + + lint-md = mkApp { + name = "lint-md"; + description = "Lint Markdown files with markdownlint"; + script = '' + exec ${pkgs.markdownlint-cli}/bin/markdownlint '**/*.md' + ''; + }; + + test-unit = mkTaggedRaceTest { + name = "test-unit"; + description = "Run unit tests with race detector; write coverage-unit.out (build tag !integration)"; + tags = "!integration"; + coverProfile = "coverage-unit.out"; + }; + + test-integration = mkTaggedRaceTest { + name = "test-integration"; + description = "Run integration tests with race detector; write coverage-integration.out (build tag integration)"; + tags = "integration"; + coverProfile = "coverage-integration.out"; + integrationTestsAtModuleRoot = true; + }; + }; + } + ); +} diff --git a/format.go b/format.go new file mode 100644 index 0000000..fc24153 --- /dev/null +++ b/format.go @@ -0,0 +1,41 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package synthra + +import ( + "fmt" + "path/filepath" + "strings" + + "gopherly.dev/synthra/codec" +) + +// extensionFormats maps file extensions to codecs for automatic format detection. +var extensionFormats = map[string]codec.Codec{ + ".yaml": codec.YAML, + ".yml": codec.YAML, + ".json": codec.JSON, + ".toml": codec.TOML, +} + +// detectFormat automatically detects the codec based on the file extension. +// It returns an error if the format cannot be determined from the extension. +func detectFormat(path string) (codec.Codec, error) { + ext := strings.ToLower(filepath.Ext(path)) + if c, ok := extensionFormats[ext]; ok { + return c, nil + } + return nil, fmt.Errorf("cannot detect format from extension %q; use WithFileAs() to specify format explicitly", ext) +} diff --git a/fuzz_test.go b/fuzz_test.go new file mode 100644 index 0000000..32e74e9 --- /dev/null +++ b/fuzz_test.go @@ -0,0 +1,310 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !integration + +package synthra + +import ( + "context" + "errors" + "testing" + + "gopherly.dev/synthra/codec" + "gopherly.dev/synthra/source" +) + +// FuzzContentSourceJSON fuzzes JSON content parsing. +func FuzzContentSourceJSON(f *testing.F) { + // Seed corpus with valid JSON inputs + f.Add([]byte(`{"foo": "bar"}`)) + f.Add([]byte(`{"nested": {"key": "value"}}`)) + f.Add([]byte(`{"array": [1, 2, 3]}`)) + f.Add([]byte(`{"bool": true, "number": 42}`)) + f.Add([]byte(`{}`)) + + f.Fuzz(func(t *testing.T, input []byte) { + cfg, err := New(WithContent(input, codec.JSON)) + if err != nil { + return + } + + // Should not panic even with invalid input + err = cfg.Load(context.Background()) + // Invalid JSON should return an error, not panic + if err != nil { + var configErr *ConfigError + if !errors.As(err, &configErr) { + // Error should be wrapped in ConfigError + t.Logf("expected ConfigError, got %T: %v", err, err) + } + } + }) +} + +// FuzzContentSourceYAML fuzzes YAML content parsing. +func FuzzContentSourceYAML(f *testing.F) { + // Seed corpus with valid YAML inputs + f.Add([]byte("foo: bar")) + f.Add([]byte("nested:\n key: value")) + f.Add([]byte("array:\n - 1\n - 2\n - 3")) + f.Add([]byte("bool: true\nnumber: 42")) + f.Add([]byte("{}")) + + f.Fuzz(func(t *testing.T, input []byte) { + cfg, err := New(WithContent(input, codec.YAML)) + if err != nil { + return + } + + // Should not panic even with invalid input + err = cfg.Load(context.Background()) + if err != nil { + var configErr *ConfigError + if !errors.As(err, &configErr) { + t.Logf("expected ConfigError, got %T: %v", err, err) + } + } + }) +} + +// FuzzContentSourceTOML fuzzes TOML content parsing. +func FuzzContentSourceTOML(f *testing.F) { + // Seed corpus with valid TOML inputs + f.Add([]byte(`foo = "bar"`)) + f.Add([]byte("[nested]\nkey = \"value\"")) + f.Add([]byte("array = [1, 2, 3]")) + f.Add([]byte("bool = true\nnumber = 42")) + + f.Fuzz(func(t *testing.T, input []byte) { + cfg, err := New(WithContent(input, codec.TOML)) + if err != nil { + return + } + + // Should not panic even with invalid input + err = cfg.Load(context.Background()) + if err != nil { + var configErr *ConfigError + if !errors.As(err, &configErr) { + t.Logf("expected ConfigError, got %T: %v", err, err) + } + } + }) +} + +// FuzzGet fuzzes key retrieval with dot notation. +func FuzzGet(f *testing.F) { + // Seed corpus with various key patterns + f.Add("foo") + f.Add("foo.bar") + f.Add("foo.bar.baz") + f.Add("a.b.c.d.e") + f.Add("") + f.Add(".") + f.Add("..") + f.Add("foo.") + f.Add(".foo") + + src := source.NewMap(map[string]any{ + "foo": "bar", + "nested": map[string]any{ + "key": "value", + "deep": map[string]any{ + "val": 42, + }, + }, + }) + cfg, err := New(WithSource(src)) + if err != nil { + f.Fatal(err) + } + if err = cfg.Load(context.Background()); err != nil { + f.Fatal(err) + } + + f.Fuzz(func(t *testing.T, key string) { + // Should not panic with any key input + _ = cfg.Get(key) + //nolint:errcheck // fuzz: only panics are failures + _, _ = cfg.String(key) + //nolint:errcheck // fuzz: only panics are failures + _, _ = cfg.Int(key) + //nolint:errcheck // fuzz: only panics are failures + _, _ = cfg.Bool(key) + //nolint:errcheck // fuzz: only panics are failures + _, _ = cfg.StringSlice(key) + //nolint:errcheck // fuzz: only panics are failures + _, _ = cfg.StringMap(key) + }) +} + +// FuzzGetWithSpecialChars fuzzes key retrieval with special characters. +func FuzzGetWithSpecialChars(f *testing.F) { + // Seed corpus with keys containing special characters + f.Add("foo-bar") + f.Add("foo_bar") + f.Add("foo:bar") + f.Add("foo/bar") + f.Add("foo\\bar") + f.Add("foo bar") + f.Add("foo\tbar") + f.Add("foo\nbar") + + src := source.NewMap(map[string]any{ + "foo-bar": "value1", + "foo_bar": "value2", + }) + cfg, err := New(WithSource(src)) + if err != nil { + f.Fatal(err) + } + if err = cfg.Load(context.Background()); err != nil { + f.Fatal(err) + } + + f.Fuzz(func(t *testing.T, key string) { + // Should not panic with any key input + _ = cfg.Get(key) + }) +} + +// FuzzValidator fuzzes custom validator functions. +func FuzzValidator(f *testing.F) { + // Seed corpus with various validation inputs + f.Add("valid") + f.Add("invalid") + f.Add("") + f.Add("test123") + + f.Fuzz(func(t *testing.T, value string) { + src := source.NewMap(map[string]any{"key": value}) + validator := func(cfg map[string]any) error { + // Simple validation that should not panic + if v, ok := cfg["key"].(string); ok && v == "" { + return errors.New("key cannot be empty") + } + return nil + } + + cfg, err := New(WithSource(src), WithValidator(validator)) + if err != nil { + return + } + + // Should not panic even with invalid input + err = cfg.Load(context.Background()) + if err != nil { + t.Logf("expected validation error for input %q: %v", value, err) + } + }) +} + +// FuzzBinding fuzzes struct binding with various inputs. +func FuzzBinding(f *testing.F) { + // Seed corpus with various string values + f.Add("test", 42) + f.Add("", 0) + f.Add("a", -1) + f.Add("very long string value", 999999) + + type TestStruct struct { + Foo string `synthra:"foo"` + Bar int `synthra:"bar"` + } + + f.Fuzz(func(t *testing.T, fooVal string, barVal int) { + src := source.NewMap(map[string]any{"foo": fooVal, "bar": barVal}) + var bind TestStruct + cfg, err := New(WithSource(src), WithBinding(&bind)) + if err != nil { + return + } + + // Should not panic with any input + err = cfg.Load(context.Background()) + if err != nil { + t.Fatal(err) + } + }) +} + +// FuzzNormalizeMapKeys fuzzes the key normalization function. +func FuzzNormalizeMapKeys(f *testing.F) { + // Seed corpus with various key patterns + f.Add("FooBar") + f.Add("foo_bar") + f.Add("FOO-BAR") + f.Add("CamelCase") + f.Add("UPPERCASE") + f.Add("lowercase") + + f.Fuzz(func(t *testing.T, key string) { + // Create a map with the fuzzed key + input := map[string]any{ + key: "value", + } + + // Should not panic with any key input + normalized := normalizeMapKeys(input) + _ = normalized + }) +} + +// FuzzGetTypedValues fuzzes type conversion functions. +func FuzzGetTypedValues(f *testing.F) { + // Seed corpus with various value types + f.Add("string", int64(42), float64(3.14), true) + f.Add("", int64(0), float64(0), false) + f.Add("test", int64(-1), float64(-1.5), true) + + f.Fuzz(func(t *testing.T, strVal string, intVal int64, floatVal float64, boolVal bool) { + src := source.NewMap(map[string]any{ + "str": strVal, + "int": intVal, + "float": floatVal, + "bool": boolVal, + }) + cfg, err := New(WithSource(src)) + if err != nil { + return + } + if err = cfg.Load(context.Background()); err != nil { + return + } + + // Should not panic with any value types + //nolint:errcheck // fuzz: only panics are failures + _, _ = cfg.String("str") + //nolint:errcheck // fuzz: only panics are failures + _, _ = cfg.Int("int") + //nolint:errcheck // fuzz: only panics are failures + _, _ = cfg.Int64("int") + //nolint:errcheck // fuzz: only panics are failures + _, _ = cfg.Float64("float") + //nolint:errcheck // fuzz: only panics are failures + _, _ = cfg.Bool("bool") + + // Try cross-type conversions (should not panic) + //nolint:errcheck // fuzz: only panics are failures + _, _ = cfg.String("int") + //nolint:errcheck // fuzz: only panics are failures + _, _ = cfg.Int("str") + //nolint:errcheck // fuzz: only panics are failures + _, _ = cfg.Bool("str") + //nolint:errcheck // fuzz: only panics are failures + _, _ = cfg.Float64("str") + }) +} diff --git a/get.go b/get.go new file mode 100644 index 0000000..8676e9d --- /dev/null +++ b/get.go @@ -0,0 +1,172 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package synthra + +import ( + "fmt" + "reflect" + "time" + + "github.com/spf13/cast" +) + +// Get returns the value associated with the given key as type T. +// If c is nil, it returns [ErrNilConfig]. +// If the key is missing or empty, or the value cannot be converted to T, +// it returns an error. +// +// Example: +// +// port, err := synthra.Get[int](cfg, "server.port") +// if err != nil { +// return fmt.Errorf("server.port: %w", err) +// } +// +// timeout, err := synthra.Get[time.Duration](cfg, "timeout") +// if err != nil { +// return err +// } +func Get[T any](c *Synthra, key string) (T, error) { + zero := getZeroValue[T]() + val, err := c.requireValue(key) + if err != nil { + return zero, err + } + + if result, ok := val.(T); ok { + return result, nil + } + + result, ok := convertToType[T](val) + if ok { + return result, nil + } + + return zero, NewConfigError(OpGet, key, fmt.Errorf("cannot convert to %T", zero)) +} + +// GetOr returns the value associated with the given key as type T. +// If the key is not found or cannot be converted to type T, it returns the +// provided default value. +// The type T is inferred from the default value. +// +// Example: +// +// port := synthra.GetOr(cfg, "server.port", 8080) // type inferred as int +// host := synthra.GetOr(cfg, "server.host", "localhost") // type inferred as string +// timeout := synthra.GetOr(cfg, "timeout", 30*time.Second) // type inferred as time.Duration +func GetOr[T any](c *Synthra, key string, defaultVal T) T { + if c == nil { + return defaultVal + } + + val := c.getValueFromMap(key) + if val == nil { + return defaultVal + } + + // Try direct type assertion first + if result, ok := val.(T); ok { + return result + } + + // Fallback to cast library for common type conversions + result, ok := convertToType[T](val) + if ok { + return result + } + + return defaultVal +} + +// getZeroValue returns a proper zero value for type T. +// For slices and maps, it returns empty initialized values instead of nil. +func getZeroValue[T any]() T { + var zero T + v := reflect.ValueOf(&zero).Elem() + + // Initialize slices and maps to empty instead of nil + switch v.Kind() { + case reflect.Slice: + v.Set(reflect.MakeSlice(v.Type(), 0, 0)) + case reflect.Map: + v.Set(reflect.MakeMap(v.Type())) + } + + return zero +} + +// convertToType attempts to convert a value to type T using the cast library. +// This handles common type conversions (int, string, bool, etc.) but won't +// work for custom types. +func convertToType[T any](val any) (T, bool) { + var zero T + var result any + + // Use type switch to handle common conversions + switch any(zero).(type) { + case string: + result = cast.ToString(val) + case int: + result = cast.ToInt(val) + case int64: + result = cast.ToInt64(val) + case int32: + result = cast.ToInt32(val) + case int16: + result = cast.ToInt16(val) + case int8: + result = cast.ToInt8(val) + case uint: + result = cast.ToUint(val) + case uint64: + result = cast.ToUint64(val) + case uint32: + result = cast.ToUint32(val) + case uint16: + result = cast.ToUint16(val) + case uint8: + result = cast.ToUint8(val) + case float64: + result = cast.ToFloat64(val) + case float32: + result = cast.ToFloat32(val) + case bool: + result = cast.ToBool(val) + case []string: + result = cast.ToStringSlice(val) + case []int: + result = cast.ToIntSlice(val) + case map[string]any: + result = cast.ToStringMap(val) + case map[string]string: + result = cast.ToStringMapString(val) + case map[string][]string: + result = cast.ToStringMapStringSlice(val) + case time.Duration: + result = cast.ToDuration(val) + case time.Time: + result = cast.ToTime(val) + default: + return zero, false + } + + // Convert result back to T + if typedResult, ok := result.(T); ok { + return typedResult, true + } + + return zero, false +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..53e8390 --- /dev/null +++ b/go.mod @@ -0,0 +1,83 @@ +module gopherly.dev/synthra + +go 1.26 + +require ( + dario.cat/mergo v1.0.2 + github.com/BurntSushi/toml v1.6.0 + github.com/go-viper/mapstructure/v2 v2.5.0 + github.com/goccy/go-yaml v1.19.2 + github.com/hashicorp/consul/api v1.34.2 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 + github.com/spf13/cast v1.10.0 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.42.0 + github.com/testcontainers/testcontainers-go/modules/consul v0.42.0 +) + +require ( + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/armon/go-metrics v0.4.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-metrics v0.5.4 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/hashicorp/serf v0.10.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.2 // indirect + github.com/moby/moby/client v0.4.1 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.4 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c2aa505 --- /dev/null +++ b/go.sum @@ -0,0 +1,366 @@ +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= +github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/consul/api v1.34.2 h1:B5jqSSKwWyY8U8WiGS5vmPEPkkF0bAvrECykdZkDR80= +github.com/hashicorp/consul/api v1.34.2/go.mod h1:+gAdHQa2zvgYX3ZfcgITtnYCSj6AgS/cgotvCKaE+b8= +github.com/hashicorp/consul/sdk v0.18.1 h1:RDTeBvAeOveI2xI86sV+8WkaN7OkP4zz+cG3fOobDCM= +github.com/hashicorp/consul/sdk v0.18.1/go.mod h1:XdP2tEJmAvlK4jgoKTTtohGkRJlS4mU44mv9/sjU21s= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-metrics v0.5.4 h1:8mmPiIJkTPPEbAiV97IxdAGNdRdaWwVap1BU6elejKY= +github.com/hashicorp/go-metrics v0.5.4/go.mod h1:CG5yz4NZ/AI/aQt9Ucm/vdBnbh7fvmv4lxZ350i+QQI= +github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= +github.com/hashicorp/go-msgpack/v2 v2.1.2 h1:4Ee8FTp834e+ewB71RDrQ0VKpyFdrKOjvYtnQ/ltVj0= +github.com/hashicorp/go-msgpack/v2 v2.1.2/go.mod h1:upybraOAblm4S7rx0+jeNy+CWWhzywQsSRV5033mMu4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-sockaddr v1.0.5 h1:dvk7TIXCZpmfOlM+9mlcrWmWjw/wlKT+VDq2wMvfPJU= +github.com/hashicorp/go-sockaddr v1.0.5/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI= +github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/memberlist v0.5.2 h1:rJoNPWZ0juJBgqn48gjy59K5H4rNgvUoM1kUD7bXiuI= +github.com/hashicorp/memberlist v0.5.2/go.mod h1:Ri9p/tRShbjYnpNf4FFPXG7wxEGY4Nrcn6E7jrVa//4= +github.com/hashicorp/serf v0.10.2 h1:m5IORhuNSjaxeljg5DeQVDlQyVkhRIjJDimbkCa8aAc= +github.com/hashicorp/serf v0.10.2/go.mod h1:T1CmSGfSeGfnfNy/w0odXQUR1rfECGd2Qdsp84DjOiY= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.1.56 h1:5imZaSeoRNvpM9SzWNhEcP9QliKiz20/dA2QabIGVnE= +github.com/miekg/dns v1.1.56/go.mod h1:cRm6Oo2C8TY9ZS/TqsSrseAcncm74lfK5G+ikN2SWWY= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= +github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= +github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY= +github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/consul v0.42.0 h1:oQqQAPaiv5WvLB6lCapjohWRbMi1pYmPSTSDQrVv3nc= +github.com/testcontainers/testcontainers-go/modules/consul v0.42.0/go.mod h1:5/t9MNZTBLJ08QzPdVe0XXjLg7W31+udMM3+hoRYXa4= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..657ecf6 --- /dev/null +++ b/integration_test.go @@ -0,0 +1,421 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build integration + +package synthra_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gopherly.dev/synthra" + "gopherly.dev/synthra/codec" + "gopherly.dev/synthra/synthratest" +) + +// TestIntegration_FileSourceWithYAML tests end-to-end YAML file loading. +func TestIntegration_FileSourceWithYAML(t *testing.T) { + t.Parallel() + + // Create temporary YAML file + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + yamlContent := []byte(` +server: + host: localhost + port: 8080 + tls: + enabled: true + cert: /path/to/cert.pem +database: + driver: postgres + host: db.example.com + port: 5432 + credentials: + user: dbuser + password: dbpass +logging: + level: info + format: json +`) + + err := os.WriteFile(configFile, yamlContent, 0o600) + require.NoError(t, err) + + // Load configuration + cfg, err := synthra.New( + synthra.WithFileAs(configFile, codec.YAML), + ) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.NoError(t, err) + + // Verify loaded values + synthratest.AssertString(t, cfg, "server.host", "localhost") + synthratest.AssertInt(t, cfg, "server.port", 8080) + synthratest.AssertBool(t, cfg, "server.tls.enabled", true) + synthratest.AssertString(t, cfg, "server.tls.cert", "/path/to/cert.pem") + synthratest.AssertString(t, cfg, "database.driver", "postgres") + synthratest.AssertString(t, cfg, "database.host", "db.example.com") + synthratest.AssertInt(t, cfg, "database.port", 5432) + synthratest.AssertString(t, cfg, "database.credentials.user", "dbuser") + synthratest.AssertString(t, cfg, "logging.level", "info") + synthratest.AssertString(t, cfg, "logging.format", "json") +} + +// TestIntegration_FileSourceWithJSON tests end-to-end JSON file loading. +func TestIntegration_FileSourceWithJSON(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.json") + + jsonContent := []byte(`{ + "app": { + "name": "MyApp", + "version": "1.0.0", + "features": ["auth", "api", "metrics"] + }, + "cache": { + "enabled": true, + "ttl": 3600 + } + }`) + + err := os.WriteFile(configFile, jsonContent, 0o600) + require.NoError(t, err) + + cfg, err := synthra.New( + synthra.WithFileAs(configFile, codec.JSON), + ) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.NoError(t, err) + + synthratest.AssertString(t, cfg, "app.name", "MyApp") + synthratest.AssertString(t, cfg, "app.version", "1.0.0") + synthratest.AssertStringSlice(t, cfg, "app.features", []string{"auth", "api", "metrics"}) + synthratest.AssertBool(t, cfg, "cache.enabled", true) + synthratest.AssertInt(t, cfg, "cache.ttl", 3600) +} + +// TestIntegration_FileSourceWithTOML tests end-to-end TOML file loading. +func TestIntegration_FileSourceWithTOML(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.toml") + + tomlContent := []byte(` +title = "My Application" + +[server] +host = "0.0.0.0" +port = 9090 + +[database] +driver = "mysql" +max_connections = 100 +`) + + err := os.WriteFile(configFile, tomlContent, 0o600) + require.NoError(t, err) + + cfg, err := synthra.New( + synthra.WithFileAs(configFile, codec.TOML), + ) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.NoError(t, err) + + synthratest.AssertString(t, cfg, "title", "My Application") + synthratest.AssertString(t, cfg, "server.host", "0.0.0.0") + synthratest.AssertInt(t, cfg, "server.port", 9090) + synthratest.AssertString(t, cfg, "database.driver", "mysql") + synthratest.AssertInt(t, cfg, "database.max_connections", 100) +} + +// TestIntegration_MultipleSources tests merging configurations from multiple sources. +func TestIntegration_MultipleSources(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + // Base configuration (defaults) + baseFile := filepath.Join(tmpDir, "base.yaml") + baseContent := []byte(` +server: + host: localhost + port: 8080 + timeout: 30 +database: + pool_size: 10 + timeout: 5 +`) + err := os.WriteFile(baseFile, baseContent, 0o600) + require.NoError(t, err) + + // Environment-specific override + envFile := filepath.Join(tmpDir, "production.yaml") + envContent := []byte(` +server: + host: 0.0.0.0 + port: 80 +database: + pool_size: 50 +`) + err = os.WriteFile(envFile, envContent, 0o600) + require.NoError(t, err) + + // Local overrides + localFile := filepath.Join(tmpDir, "local.yaml") + localContent := []byte(` +server: + port: 9090 +`) + err = os.WriteFile(localFile, localContent, 0o600) + require.NoError(t, err) + + // Load all sources (later sources override earlier ones) + cfg, err := synthra.New( + synthra.WithFileAs(baseFile, codec.YAML), + synthra.WithFileAs(envFile, codec.YAML), + synthra.WithFileAs(localFile, codec.YAML), + ) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.NoError(t, err) + + // Verify merged values + synthratest.AssertString(t, cfg, "server.host", "0.0.0.0") // from production + synthratest.AssertInt(t, cfg, "server.port", 9090) // from local (highest priority) + synthratest.AssertInt(t, cfg, "server.timeout", 30) // from base (not overridden) + synthratest.AssertInt(t, cfg, "database.pool_size", 50) // from production + synthratest.AssertInt(t, cfg, "database.timeout", 5) // from base (not overridden) +} + +// TestIntegration_BindingWithValidation tests struct binding with validation. +func TestIntegration_BindingWithValidation(t *testing.T) { + t.Parallel() + + type ServerConfig struct { + Host string `synthra:"host"` + Port int `synthra:"port"` + } + + type DatabaseConfig struct { + Driver string `synthra:"driver"` + Host string `synthra:"host"` + Port int `synthra:"port"` + Username string `synthra:"username"` + Password string `synthra:"password"` + } + + type AppConfig struct { + Server ServerConfig `synthra:"server"` + Database DatabaseConfig `synthra:"database"` + } + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + yamlContent := []byte(` +server: + host: localhost + port: 8080 +database: + driver: postgres + host: localhost + port: 5432 + username: testuser + password: testpass +`) + + err := os.WriteFile(configFile, yamlContent, 0o600) + require.NoError(t, err) + + var appConfig AppConfig + cfg, err := synthra.New( + synthra.WithFileAs(configFile, codec.YAML), + synthra.WithBinding(&appConfig), + ) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.NoError(t, err) + + // Verify bound struct + assert.Equal(t, "localhost", appConfig.Server.Host) + assert.Equal(t, 8080, appConfig.Server.Port) + assert.Equal(t, "postgres", appConfig.Database.Driver) + assert.Equal(t, "localhost", appConfig.Database.Host) + assert.Equal(t, 5432, appConfig.Database.Port) + assert.Equal(t, "testuser", appConfig.Database.Username) + assert.Equal(t, "testpass", appConfig.Database.Password) +} + +// TestIntegration_ReloadConfiguration tests reloading configuration. +func TestIntegration_ReloadConfiguration(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + // Initial configuration + initialContent := []byte(` +version: 1 +feature_flags: + new_ui: false +`) + + err := os.WriteFile(configFile, initialContent, 0o600) + require.NoError(t, err) + + cfg, err := synthra.New( + synthra.WithFileAs(configFile, codec.YAML), + ) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.NoError(t, err) + + synthratest.AssertInt(t, cfg, "version", 1) + synthratest.AssertBool(t, cfg, "feature_flags.new_ui", false) + + // Update configuration file + updatedContent := []byte(` +version: 2 +feature_flags: + new_ui: true +`) + + err = os.WriteFile(configFile, updatedContent, 0o600) + require.NoError(t, err) + + // Reload configuration + err = cfg.Load(context.Background()) + require.NoError(t, err) + + synthratest.AssertInt(t, cfg, "version", 2) + synthratest.AssertBool(t, cfg, "feature_flags.new_ui", true) +} + +// TestIntegration_FileDumper tests dumping configuration to a file. +func TestIntegration_FileDumper(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + sourceFile := filepath.Join(tmpDir, "source.yaml") + dumpFile := filepath.Join(tmpDir, "dump.yaml") + + sourceContent := []byte(` +app: + name: TestApp + version: 1.0.0 +`) + + err := os.WriteFile(sourceFile, sourceContent, 0o600) + require.NoError(t, err) + + cfg, err := synthra.New( + synthra.WithFileAs(sourceFile, codec.YAML), + synthra.WithFileDumperAs(dumpFile, codec.YAML), + ) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.NoError(t, err) + + // Dump configuration + err = cfg.Dump(context.Background()) + require.NoError(t, err) + + // Verify dumped file exists and contains correct data + //nolint:gosec // Test file read is safe + dumpedContent, err := os.ReadFile(dumpFile) + require.NoError(t, err) + assert.Contains(t, string(dumpedContent), "TestApp") + assert.Contains(t, string(dumpedContent), "1.0.0") +} + +// TestIntegration_CaseInsensitiveKeys tests case-insensitive key access. +func TestIntegration_CaseInsensitiveKeys(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + yamlContent := []byte(` +Server: + Host: localhost + Port: 8080 +Database: + Driver: postgres +`) + + err := os.WriteFile(configFile, yamlContent, 0o600) + require.NoError(t, err) + + cfg, err := synthra.New( + synthra.WithFileAs(configFile, codec.YAML), + ) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.NoError(t, err) + + // All case variations should work + synthratest.AssertString(t, cfg, "server.host", "localhost") + synthratest.AssertString(t, cfg, "Server.Host", "localhost") + synthratest.AssertString(t, cfg, "SERVER.HOST", "localhost") + synthratest.AssertInt(t, cfg, "server.port", 8080) + synthratest.AssertInt(t, cfg, "Server.Port", 8080) + synthratest.AssertString(t, cfg, "database.driver", "postgres") + synthratest.AssertString(t, cfg, "DATABASE.DRIVER", "postgres") +} + +// TestIntegration_EnvironmentVariables tests environment variable source. +func TestIntegration_EnvironmentVariables(t *testing.T) { + // NOTE: Cannot use t.Parallel() with t.Setenv() + + // Set test environment variables + t.Setenv("TESTAPP_SERVER_HOST", "envhost") + t.Setenv("TESTAPP_SERVER_PORT", "9090") + t.Setenv("TESTAPP_DEBUG", "true") + + cfg, err := synthra.New( + synthra.WithEnv("TESTAPP_"), + ) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.NoError(t, err) + + // Environment variables should be accessible with dot notation + synthratest.AssertString(t, cfg, "server.host", "envhost") + synthratest.AssertString(t, cfg, "server.port", "9090") + synthratest.AssertString(t, cfg, "debug", "true") +} diff --git a/source.go b/source.go new file mode 100644 index 0000000..7d0a188 --- /dev/null +++ b/source.go @@ -0,0 +1,30 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package synthra + +import "context" + +// Source defines the interface for configuration sources. +// Implementations load configuration data from various locations +// such as files, environment variables, or remote services. +// +// Load must be safe to call concurrently. +type Source interface { + // Load loads configuration data from the source. + // It returns a map containing the configuration key-value pairs. + // Keys are normalized to lowercase for case-insensitive access. + Load(ctx context.Context) (map[string]any, error) +} diff --git a/source/consul.go b/source/consul.go new file mode 100644 index 0000000..03d52ed --- /dev/null +++ b/source/consul.go @@ -0,0 +1,97 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package source + +import ( + "context" + "fmt" + + "github.com/hashicorp/consul/api" + "gopherly.dev/synthra/codec" +) + +// ConsulKV defines the interface for Consul key-value operations. +// This interface enables testing by allowing mock implementations. +type ConsulKV interface { + Get(key string, q *api.QueryOptions) (*api.KVPair, *api.QueryMeta, error) +} + +// Consul represents a configuration source that loads data from Consul's +// key-value store. +// +// The Consul client is configured using environment variables: +// - CONSUL_HTTP_ADDR: The address of the Consul server +// (e.g., "http://localhost:8500") +// - CONSUL_HTTP_TOKEN: The access token for authentication (optional) +type Consul struct { + client *api.Client + kv ConsulKV + path string + lastIndex uint64 + decoder codec.Decoder +} + +// NewConsul creates a new Consul configuration source with the given path +// and decoder. +// The path parameter specifies the key path in Consul's key-value store. +// If kv is nil, it uses the default Consul client KV implementation. +// +// Errors: +// - Returns error if the Consul client cannot be created +func NewConsul(path string, decoder codec.Decoder, kv ConsulKV) (*Consul, error) { + client, err := api.NewClient(api.DefaultConfig()) + if err != nil { + return nil, fmt.Errorf("failed to create consul client: %w", err) + } + if kv == nil { + kv = client.KV() + } + return &Consul{ + client: client, + kv: kv, + path: path, + decoder: decoder, + }, nil +} + +// Load retrieves configuration data from the Consul key-value store at +// the configured path. +// If the key does not exist in Consul, it returns an empty map without error. +// +// Errors: +// - Returns error if the Consul query fails +// - Returns error if decoding the value fails +func (c *Consul) Load(ctx context.Context) (map[string]any, error) { + pair, meta, err := c.kv.Get(c.path, (&api.QueryOptions{}).WithContext(ctx)) + if err != nil { + return nil, fmt.Errorf("failed to get consul key: %w", err) + } + + if pair == nil { + return make(map[string]any), nil + } + + if meta != nil { + c.lastIndex = meta.LastIndex + } + + var config map[string]any + if err = c.decoder.Decode(pair.Value, &config); err != nil { + return nil, fmt.Errorf("failed to decode consul value: %w", err) + } + + return config, nil +} diff --git a/source/consul_test.go b/source/consul_test.go new file mode 100644 index 0000000..0704790 --- /dev/null +++ b/source/consul_test.go @@ -0,0 +1,553 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !integration + +package source + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/hashicorp/consul/api" + "github.com/stretchr/testify/suite" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/log" + "github.com/testcontainers/testcontainers-go/modules/consul" + "gopherly.dev/synthra/codec" +) + +// ConsulSourceTestSuite is a test suite for the Consul source +type ConsulSourceTestSuite struct { + suite.Suite + consul *consul.ConsulContainer + client *api.Client +} + +// SetupSuite sets up the test suite +func (s *ConsulSourceTestSuite) SetupSuite() { + ctx := context.Background() + + // Start Consul container with random port + container, err := consul.Run(ctx, "hashicorp/consul:1.15", testcontainers.WithLogger(log.TestLogger(s.T()))) + s.Require().NoError(err) + s.consul = container + + endpoint, err := container.ApiEndpoint(ctx) + s.Require().NoError(err) + + // Set the Consul HTTP address environment variable to allow Synthra to connect to the Consul server + s.T().Setenv("CONSUL_HTTP_ADDR", endpoint) + + // Create Consul client + config := api.DefaultConfig() + config.Address = endpoint + s.client, err = api.NewClient(config) + s.Require().NoError(err) +} + +// TearDownSuite tears down the test suite +func (s *ConsulSourceTestSuite) TearDownSuite() { + ctx := context.Background() + if s.consul != nil { + s.Require().NoError(s.consul.Terminate(ctx)) + } +} + +// TestConsulSourceTestSuite runs the test suite +func TestConsulSourceTestSuite(t *testing.T) { + suite.Run(t, new(ConsulSourceTestSuite)) +} + +// TestLoad_ValuePresent tests the Load method with a value present +func (s *ConsulSourceTestSuite) TestLoad_ValuePresent() { + // Set up test data + key := "test/value-present" + value := `{"foo": "bar"}` + _, err := s.client.KV().Put(&api.KVPair{ + Key: key, + Value: []byte(value), + }, nil) + s.Require().NoError(err) + + // Create Consul source + consul, err := NewConsul(key, codec.JSON, nil) + s.Require().NoError(err) + + // Load configuration + conf, err := consul.Load(context.Background()) + s.Require().NoError(err) + s.Equal("bar", conf["foo"]) +} + +// TestLoad_ValueAbsent tests the Load method with a value absent +func (s *ConsulSourceTestSuite) TestLoad_ValueAbsent() { + // Create Consul source with non-existent key + consul, err := NewConsul("test/value-absent", codec.JSON, nil) + s.Require().NoError(err) + + // Load configuration + conf, err := consul.Load(context.Background()) + s.Require().NoError(err) + s.Empty(conf) +} + +// TestLoad_DecodeError tests the Load method with a decode error +func (s *ConsulSourceTestSuite) TestLoad_DecodeError() { + // Set up test data with invalid JSON + key := "test/decode-error" + value := `{"foo": "bar"` // Invalid JSON + _, err := s.client.KV().Put(&api.KVPair{ + Key: key, + Value: []byte(value), + }, nil) + s.Require().NoError(err) + + // Create Consul source + consul, err := NewConsul(key, codec.JSON, nil) + s.Require().NoError(err) + + // Load configuration + _, err = consul.Load(context.Background()) + s.Require().Error(err) + s.Contains(err.Error(), "failed to decode consul value: unexpected end of JSON input") +} + +// TestLoad_WithJSONValue tests the Load method with a JSON value +func (s *ConsulSourceTestSuite) TestLoad_WithJSONValue() { + // Set up test data + key := "test/config" + value := `{"foo": "bar", "baz": 42}` + _, err := s.client.KV().Put(&api.KVPair{ + Key: key, + Value: []byte(value), + }, nil) + s.Require().NoError(err) + + // Create Consul source + consul, err := NewConsul(key, codec.JSON, nil) + s.Require().NoError(err) + + // Load configuration + conf, err := consul.Load(context.Background()) + s.Require().NoError(err) + s.Equal("bar", conf["foo"]) + s.Equal(float64(42), conf["baz"]) +} + +// TestLoad_WithYAMLValue tests the Load method with a YAML value +func (s *ConsulSourceTestSuite) TestLoad_WithYAMLValue() { + // Set up test data + key := "test/yaml" + value := ` +foo: bar +baz: 42 +nested: + key: value +` + _, err := s.client.KV().Put(&api.KVPair{ + Key: key, + Value: []byte(value), + }, nil) + s.Require().NoError(err) + + // Create Consul source + consul, err := NewConsul(key, codec.YAML, nil) + s.Require().NoError(err) + + // Load configuration + conf, err := consul.Load(context.Background()) + s.Require().NoError(err) + s.Equal("bar", conf["foo"]) + s.Equal(uint64(42), conf["baz"]) + nested, ok := conf["nested"].(map[string]any) + s.Require().True(ok) + s.Equal("value", nested["key"]) +} + +// TestLoad_WithParseFloat64 tests the Load method with a ParseFloat64 +// scalar decoder +func (s *ConsulSourceTestSuite) TestLoad_WithParseFloat64() { + // Set up test data + key := "test/caster" + value := `42` + _, err := s.client.KV().Put(&api.KVPair{ + Key: key, + Value: []byte(value), + }, nil) + s.Require().NoError(err) + + // Create Consul source with scalar decoder + consul, err := NewConsul(key, codec.ParseFloat64("caster"), nil) + s.Require().NoError(err) + + // Load configuration + conf, err := consul.Load(context.Background()) + s.Require().NoError(err) + v, ok := conf["caster"].(float64) + s.Require().True(ok) + s.InDelta(float64(42), v, 0.001) +} + +// TestLoad_WithContextTimeout tests the Load method with a context timeout +func (s *ConsulSourceTestSuite) TestLoad_WithContextTimeout() { + // Create mock KV that delays for 100ms + mockKV := &mockConsulKV{delay: 100 * time.Millisecond} + + // Create Consul source with mock KV + consul, err := NewConsul("test/timeout", codec.JSON, mockKV) + s.Require().NoError(err) + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + // Load configuration with timeout + _, err = consul.Load(ctx) + s.Require().Error(err) + s.Contains(err.Error(), "context deadline exceeded") +} + +// TestLoad_WithNonExistentKey tests the Load method with a non-existent key +func (s *ConsulSourceTestSuite) TestLoad_WithNonExistentKey() { + // Create Consul source with non-existent key + consul, err := NewConsul("non/existent/key", codec.JSON, nil) + s.Require().NoError(err) + + // Load configuration + conf, err := consul.Load(context.Background()) + s.Require().NoError(err) + s.Empty(conf) +} + +// TestLoad_WithClientInitFailure tests the Load method with a client +// initialization failure +func (s *ConsulSourceTestSuite) TestLoad_WithClientInitFailure() { + // Create a mock KV that will be used to simulate client initialization failure + mockKV := &mockConsulKV{err: fmt.Errorf("client initialization failed")} + + // Create Consul source with mock KV + consul, err := NewConsul("test/key", codec.JSON, mockKV) + s.Require().NoError(err) + + // Load configuration + _, err = consul.Load(context.Background()) + s.Require().Error(err) + s.Contains(err.Error(), "client initialization failed") +} + +// TestLoad_WithKVOperationFailure tests the Load method with a KV +// operation failure +func (s *ConsulSourceTestSuite) TestLoad_WithKVOperationFailure() { + // Create mock KV that fails + mockKV := &mockConsulKV{err: errors.New("KV operation failed")} + + // Create Consul source with mock KV + consul, err := NewConsul("test/key", codec.JSON, mockKV) + s.Require().NoError(err) + + // Load configuration + _, err = consul.Load(context.Background()) + s.Require().Error(err) + s.Contains(err.Error(), "KV operation failed") +} + +// TestLoad_WithSpecialCharacters tests the Load method with special +// characters in the key +func (s *ConsulSourceTestSuite) TestLoad_WithSpecialCharacters() { + // Set up test data with special characters in key + key := "test/special/chars/!@#$%^&*()" + value := `{"foo": "bar"}` + _, err := s.client.KV().Put(&api.KVPair{ + Key: key, + Value: []byte(value), + }, nil) + s.Require().NoError(err) + + // Create Consul source + consul, err := NewConsul(key, codec.JSON, nil) + s.Require().NoError(err) + + // Load configuration + conf, err := consul.Load(context.Background()) + s.Require().NoError(err) + s.Equal("bar", conf["foo"]) +} + +// TestLoad_WithEmptyValue tests the Load method with an empty value +func (s *ConsulSourceTestSuite) TestLoad_WithEmptyValue() { + // Set up test data with empty value + key := "test/empty-value" + _, err := s.client.KV().Put(&api.KVPair{ + Key: key, + Value: []byte{}, + }, nil) + s.Require().NoError(err) + + // Create Consul source + consul, err := NewConsul(key, codec.JSON, nil) + s.Require().NoError(err) + + // Load configuration + _, err = consul.Load(context.Background()) + s.Require().Error(err) + s.Contains(err.Error(), "failed to decode consul value") + s.Contains(err.Error(), "unexpected end of JSON input") +} + +func (s *ConsulSourceTestSuite) TestLoad_WithLargeValue() { + // Set up test data with large value (500KB, just under Consul's 512KB limit) + key := "test/large-value" + largeValue := make([]byte, 0, 500*1024) // 500KB + for i := range largeValue { + largeValue[i] = byte(i % 256) + } + _, err := s.client.KV().Put(&api.KVPair{ + Key: key, + Value: largeValue, + }, nil) + s.Require().NoError(err) + + // Create Consul source + consul, err := NewConsul(key, codec.JSON, nil) + s.Require().NoError(err) + + // Load configuration + _, err = consul.Load(context.Background()) + s.Require().Error(err) // Should fail due to invalid JSON + s.Contains(err.Error(), "failed to decode consul value") +} + +// TestLoad_WithBinaryValue tests the Load method with a binary value +// using ParseString +func (s *ConsulSourceTestSuite) TestLoad_WithBinaryValue() { + // Set up test data with binary value + key := "test/binary-value" + binaryValue := []byte{0x00, 0x01, 0x02, 0x03, 0x04} + _, err := s.client.KV().Put(&api.KVPair{ + Key: key, + Value: binaryValue, + }, nil) + s.Require().NoError(err) + + // Create Consul source with string scalar decoder + consul, err := NewConsul(key, codec.ParseString("binary-value"), nil) + s.Require().NoError(err) + + // Load configuration + conf, err := consul.Load(context.Background()) + s.Require().NoError(err) + // ParseString trims whitespace; binary bytes with no surrounding whitespace should be preserved + s.Equal(string(binaryValue), conf["binary-value"]) +} + +// TestLoad_WithMultipleValues tests the Load method with multiple values +func (s *ConsulSourceTestSuite) TestLoad_WithMultipleValues() { + // Set up test data with multiple values + keys := []string{ + "test/multi/1", + "test/multi/2", + "test/multi/3", + } + values := []string{ + `{"foo": "bar1"}`, + `{"foo": "bar2"}`, + `{"foo": "bar3"}`, + } + + for i, key := range keys { + _, err := s.client.KV().Put(&api.KVPair{ + Key: key, + Value: []byte(values[i]), + }, nil) + s.Require().NoError(err) + } + + // Test each key + for i, key := range keys { + consul, err := NewConsul(key, codec.JSON, nil) + s.Require().NoError(err) + + conf, err := consul.Load(context.Background()) + s.Require().NoError(err) + s.Equal(fmt.Sprintf("bar%d", i+1), conf["foo"]) + } +} + +// TestLoad_WithNestedValues tests the Load method with nested values +func (s *ConsulSourceTestSuite) TestLoad_WithNestedValues() { + // Set up test data with nested values + key := "test/nested" + value := `{ + "level1": { + "level2": { + "level3": { + "value": "deep" + } + } + } + }` + _, err := s.client.KV().Put(&api.KVPair{ + Key: key, + Value: []byte(value), + }, nil) + s.Require().NoError(err) + + // Create Consul source + consul, err := NewConsul(key, codec.JSON, nil) + s.Require().NoError(err) + + // Load configuration + conf, err := consul.Load(context.Background()) + s.Require().NoError(err) + + level1, ok := conf["level1"].(map[string]any) + s.True(ok) + level2, ok := level1["level2"].(map[string]any) + s.True(ok) + level3, ok := level2["level3"].(map[string]any) + s.True(ok) + s.Equal("deep", level3["value"]) +} + +// TestLoad_WithConsulError tests the Load method with a Consul error +func (s *ConsulSourceTestSuite) TestLoad_WithConsulError() { + // Create mock KV that returns a Consul error + mockKV := &mockConsulKV{err: fmt.Errorf("consul error: connection refused")} + + // Create Consul source with mock KV + consul, err := NewConsul("test/key", codec.JSON, mockKV) + s.Require().NoError(err) + + // Load configuration + _, err = consul.Load(context.Background()) + s.Require().Error(err) + s.Contains(err.Error(), "consul error: connection refused") +} + +// TestLoad_WithNilValue tests the Load method with a nil value +func (s *ConsulSourceTestSuite) TestLoad_WithNilValue() { + // Set up test data with nil value + key := "test/nil-value" + _, err := s.client.KV().Put(&api.KVPair{ + Key: key, + Value: nil, + }, nil) + s.Require().NoError(err) + + // Create Consul source + consul, err := NewConsul(key, codec.JSON, nil) + s.Require().NoError(err) + + // Load configuration + _, err = consul.Load(context.Background()) + s.Require().Error(err) + s.Contains(err.Error(), "failed to decode consul value") + s.Contains(err.Error(), "unexpected end of JSON input") +} + +// TestLoad_WithInvalidJSON tests the Load method with an invalid JSON value +func (s *ConsulSourceTestSuite) TestLoad_WithInvalidJSON() { + // Set up test data with invalid JSON + key := "test/invalid-json" + value := `{"foo": "bar", "baz": }` // Invalid JSON + _, err := s.client.KV().Put(&api.KVPair{ + Key: key, + Value: []byte(value), + }, nil) + s.Require().NoError(err) + + // Create Consul source + consul, err := NewConsul(key, codec.JSON, nil) + s.Require().NoError(err) + + // Load configuration + _, err = consul.Load(context.Background()) + s.Require().Error(err) + s.Contains(err.Error(), "failed to decode consul value") +} + +// TestLoad_WithInvalidYAML tests the Load method with an invalid YAML value +func (s *ConsulSourceTestSuite) TestLoad_WithInvalidYAML() { + // Set up test data with invalid YAML + key := "test/invalid-yaml" + value := ` +foo: bar + baz: qux + invalid: indentation +` + _, err := s.client.KV().Put(&api.KVPair{ + Key: key, + Value: []byte(value), + }, nil) + s.Require().NoError(err) + + // Create Consul source + consul, err := NewConsul(key, codec.YAML, nil) + s.Require().NoError(err) + + // Load configuration + _, err = consul.Load(context.Background()) + s.Error(err) + s.Contains(err.Error(), "failed to decode consul value") +} + +// TestLoad_WithInvalidParseIntValue tests the Load method with an invalid +// value for ParseInt +func (s *ConsulSourceTestSuite) TestLoad_WithInvalidParseIntValue() { + // Set up test data with invalid value for int parsing + key := "test/invalid-caster" + value := `not-a-number` + _, err := s.client.KV().Put(&api.KVPair{ + Key: key, + Value: []byte(value), + }, nil) + s.Require().NoError(err) + + // Create Consul source with scalar int decoder + consul, err := NewConsul(key, codec.ParseInt("test"), nil) + s.Require().NoError(err) + + // Load configuration + _, err = consul.Load(context.Background()) + s.Require().Error(err) + s.Contains(err.Error(), "failed to decode consul value") +} + +// mockConsulKV is a mock implementation of the ConsulKV interface for testing +type mockConsulKV struct { + err error + delay time.Duration +} + +// Get is a mock implementation of the ConsulKV interface +func (m *mockConsulKV) Get(_ string, q *api.QueryOptions) (*api.KVPair, *api.QueryMeta, error) { + if m.delay > 0 { + ctx := q.Context() + select { + case <-time.After(m.delay): + // Continue after delay + case <-ctx.Done(): + return nil, nil, ctx.Err() + } + } + if m.err != nil { + return nil, nil, m.err + } + return nil, nil, nil +} diff --git a/source/doc.go b/source/doc.go new file mode 100644 index 0000000..818f969 --- /dev/null +++ b/source/doc.go @@ -0,0 +1,46 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package source provides configuration source implementations. +// +// Types here load data from various locations and satisfy +// [gopherly.dev/synthra.Source] for use with [gopherly.dev/synthra.WithSource] +// and related options. +// +// # Available Sources +// +// - File: Load configuration from the host file system +// - FileFS: Load configuration from a path inside an [io/fs.FS] +// - Map: In-memory map (defaults, embedded trees, tests) +// - OSEnvVar: Load configuration from environment variables +// - Consul: Load configuration from Consul key-value store +// +// # Example +// +// Creating a file source: +// +// fileSource := source.NewFile("config.yaml", codec.YAML) +// config, err := fileSource.Load(context.Background()) +// +// Creating an environment variable source: +// +// envSource := source.NewOSEnvVar("APP_") +// config, err := envSource.Load(context.Background()) +// +// Loading from an [io/fs.FS] (for example [testing/fstest.MapFS] or [embed.FS]): +// +// fsys := fstest.MapFS{"cfg.yaml": &fstest.MapFile{Data: []byte("k: v\n")}} +// fsSrc := source.NewFileFS(fsys, "cfg.yaml", codec.YAML) +// config, err := fsSrc.Load(context.Background()) +package source diff --git a/source/env.go b/source/env.go new file mode 100644 index 0000000..cfb8ec2 --- /dev/null +++ b/source/env.go @@ -0,0 +1,81 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package source + +import ( + "context" + "fmt" + "os" + "strings" + + "gopherly.dev/synthra/codec" +) + +// OSEnvVar represents a configuration source that loads data from +// environment variables. +// It filters environment variables by prefix and creates nested +// configuration structures based on underscore-separated variable names. +// +// For example, with prefix "APP_", the environment variable +// "APP_SERVER_PORT" becomes the configuration key "server.port". +type OSEnvVar struct { + prefix string + decoder codec.Decoder +} + +// NewOSEnvVar creates a new OSEnvVar source with the specified prefix. +// Only environment variables starting with this prefix will be loaded. +// The prefix is stripped from variable names before processing. +func NewOSEnvVar(prefix string) *OSEnvVar { + return &OSEnvVar{ + prefix: prefix, + decoder: codec.EnvVar, + } +} + +// Load reads environment variables with the configured prefix and +// decodes them into a map[string]any. +// Variable names are converted to lowercase and underscores create +// nested structures. +// +// Example: +// +// APP_SERVER_PORT=8080 -> server.port = "8080" +// APP_SERVER_HOST=localhost -> server.host = "localhost" +// APP_DEBUG=true -> debug = "true" +// +// Errors: +// - Returns error if decoding fails +func (e *OSEnvVar) Load(_ context.Context) (map[string]any, error) { + validEnv := make([]string, 0, len(os.Environ())) + + for _, env := range os.Environ() { + if !strings.HasPrefix(env, e.prefix) { + continue + } + + validEnv = append(validEnv, strings.TrimPrefix(env, e.prefix)) + } + + data := strings.Join(validEnv, "\n") + + var config map[string]any + if err := e.decoder.Decode([]byte(data), &config); err != nil { + return nil, fmt.Errorf("failed to decode environment variables: %w", err) + } + + return config, nil +} diff --git a/source/env_test.go b/source/env_test.go new file mode 100644 index 0000000..0d4a1a8 --- /dev/null +++ b/source/env_test.go @@ -0,0 +1,86 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !integration + +package source + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/suite" +) + +type OSEnvVarTestSuite struct { + suite.Suite +} + +func (s *OSEnvVarTestSuite) SetupTest() {} + +func TestOSEnvVarTestSuite(t *testing.T) { + suite.Run(t, new(OSEnvVarTestSuite)) +} + +func (s *OSEnvVarTestSuite) TestLoad_Simple() { + s.T().Setenv("FOO", "bar") + s.T().Setenv("BAZ", "qux") + + loader := NewOSEnvVar("") + conf, err := loader.Load(context.TODO()) + s.NoError(err) + s.Equal("bar", conf["foo"]) + s.Equal("qux", conf["baz"]) +} + +func (s *OSEnvVarTestSuite) TestLoad_Nested() { + s.T().Setenv("DATABASE_HOST", "localhost") + s.T().Setenv("DATABASE_PORT", "5432") + s.T().Setenv("DATABASE_USER_NAME", "admin") + + loader := NewOSEnvVar("") + conf, err := loader.Load(context.TODO()) + s.NoError(err) + db, ok := conf["database"].(map[string]any) + s.True(ok) + s.Equal("localhost", db["host"]) + s.Equal("5432", db["port"]) + user, ok := db["user"].(map[string]any) + s.True(ok) + s.Equal("admin", user["name"]) +} + +func (s *OSEnvVarTestSuite) TestLoad_Empty() { + // Unset all env vars that might be set by other tests + os.Clearenv() + loader := NewOSEnvVar("") + conf, err := loader.Load(context.TODO()) + s.NoError(err) + s.Empty(conf) +} + +func (s *OSEnvVarTestSuite) TestLoad_Prefix() { + s.T().Setenv("APP_FOO", "bar") + s.T().Setenv("APP_BAR", "baz") + s.T().Setenv("OTHER", "skip") + + loader := NewOSEnvVar("APP_") + conf, err := loader.Load(context.TODO()) + s.NoError(err) + s.Equal("bar", conf["foo"]) + s.Equal("baz", conf["bar"]) + s.NotContains(conf, "other") +} diff --git a/source/file.go b/source/file.go new file mode 100644 index 0000000..d796af8 --- /dev/null +++ b/source/file.go @@ -0,0 +1,80 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package source + +import ( + "context" + "fmt" + "os" + + "gopherly.dev/synthra/codec" +) + +// File represents a configuration source that loads data from a file or +// byte content. +// It supports loading from file paths or directly from byte slices. +type File struct { + path string + data []byte + decoder codec.Decoder +} + +// NewFile creates a new File source that loads configuration from the +// specified file path. +// The decoder parameter determines how the file content is parsed. +func NewFile(path string, decoder codec.Decoder) *File { + return &File{ + path: path, + decoder: decoder, + } +} + +// NewFileContent creates a new File source that loads configuration from +// the provided byte slice. +// This is useful for loading configuration from embedded content or +// dynamically generated data. +func NewFileContent(data []byte, decoder codec.Decoder) *File { + return &File{ + data: data, + decoder: decoder, + } +} + +// Load reads the configuration file and decodes its contents into a +// map[string]any. +// If the File was created with NewFile, it reads from the file system. +// If the File was created with NewFileContent, it uses the provided byte content. +// +// Errors: +// - Returns error if the file cannot be read (NewFile only) +// - Returns error if decoding fails +func (f *File) Load(context.Context) (map[string]any, error) { + var err error + + if f.path != "" { + f.data, err = os.ReadFile(f.path) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + } + + var config map[string]any + if err = f.decoder.Decode(f.data, &config); err != nil { + return nil, fmt.Errorf("failed to decode file: %w", err) + } + + return config, nil +} diff --git a/source/file_test.go b/source/file_test.go new file mode 100644 index 0000000..aeed466 --- /dev/null +++ b/source/file_test.go @@ -0,0 +1,113 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !integration + +package source + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/suite" +) + +type FileSourceTestSuite struct { + suite.Suite + tmpFile string +} + +func (s *FileSourceTestSuite) SetupTest() { + f, err := os.CreateTemp("", "filesource_test_*.json") + s.Require().NoError(err) + + s.tmpFile = f.Name() + _, err = f.WriteString(`{"foo": "bar"}`) + s.Require().NoError(err) + s.Require().NoError(f.Close()) +} + +func (s *FileSourceTestSuite) TearDownTest() { + if s.tmpFile != "" { + s.Require().NoError(os.Remove(s.tmpFile)) + } +} + +func TestFileSourceTestSuite(t *testing.T) { + suite.Run(t, new(FileSourceTestSuite)) +} + +func (s *FileSourceTestSuite) TestLoad_ValidFile() { + decoder := &mockDecoderFile{decodeMap: map[string]any{"foo": "bar"}} + file := NewFile(s.tmpFile, decoder) + conf, err := file.Load(context.TODO()) + s.NoError(err) + s.Equal(map[string]any{"foo": "bar"}, conf) +} + +func (s *FileSourceTestSuite) TestLoad_EmptyFile() { + f, err := os.CreateTemp("", "filesource_empty_*.json") + s.Require().NoError(err) + tmp := f.Name() + s.Require().NoError(f.Close()) + defer func() { + s.Require().NoError(os.Remove(tmp)) + }() + decoder := &mockDecoderFile{decodeMap: map[string]any{}} + file := NewFile(tmp, decoder) + conf, err := file.Load(context.TODO()) + s.NoError(err) + s.Empty(conf) +} + +func (s *FileSourceTestSuite) TestLoad_InvalidFile() { + file := NewFile("/invalid/path/shouldfail.json", &mockDecoderFile{}) + _, err := file.Load(context.TODO()) + s.Error(err) +} + +func (s *FileSourceTestSuite) TestLoad_Content() { + decoder := &mockDecoderFile{decodeMap: map[string]any{"foo": "bar"}} + file := NewFileContent([]byte(`{"foo": "bar"}`), decoder) + conf, err := file.Load(context.TODO()) + s.NoError(err) + s.Equal(map[string]any{"foo": "bar"}, conf) +} + +func (s *FileSourceTestSuite) TestLoad_DecodeError() { + decoder := &mockDecoderFile{err: true} + file := NewFile(s.tmpFile, decoder) + _, err := file.Load(context.TODO()) + s.Error(err) +} + +// mockDecoderFile implements codec.Decoder for testing + +type mockDecoderFile struct { + decodeMap map[string]any + err bool +} + +func (m *mockDecoderFile) Decode(_ []byte, v any) error { + if m.err { + return os.ErrInvalid + } + if ptr, ok := v.(*map[string]any); ok { + *ptr = m.decodeMap + return nil + } + return os.ErrInvalid +} diff --git a/source/fs.go b/source/fs.go new file mode 100644 index 0000000..cf9f252 --- /dev/null +++ b/source/fs.go @@ -0,0 +1,62 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package source + +import ( + "context" + "fmt" + "io/fs" + + "gopherly.dev/synthra/codec" +) + +// FileFS loads configuration from a single file inside an [io/fs.FS]. +// Concurrent use is OK when the underlying [fs.FS] is safe for concurrent use. +type FileFS struct { + fsys fs.FS + name string + decoder codec.Decoder +} + +// NewFileFS returns a source that reads name from fsys and decodes bytes with +// decoder. +// name must use slash-separated paths as required by [fs.FS] +// (for example "config/app.yaml"). +func NewFileFS(fsys fs.FS, name string, decoder codec.Decoder) *FileFS { + return &FileFS{fsys: fsys, name: name, decoder: decoder} +} + +// Load reads the named file from fsys and decodes it into a map[string]any. +// Keys in the returned map are not normalized; the Synthra loader normalizes keys. +func (f *FileFS) Load(_ context.Context) (map[string]any, error) { + if f.fsys == nil { + return nil, fmt.Errorf("file fs source: filesystem is nil") + } + if f.decoder == nil { + return nil, fmt.Errorf("file fs source: decoder is nil") + } + + data, err := fs.ReadFile(f.fsys, f.name) + if err != nil { + return nil, fmt.Errorf("file fs source: read %q: %w", f.name, err) + } + + var config map[string]any + if err = f.decoder.Decode(data, &config); err != nil { + return nil, fmt.Errorf("file fs source: decode %q: %w", f.name, err) + } + + return config, nil +} diff --git a/source/fs_test.go b/source/fs_test.go new file mode 100644 index 0000000..7da98f8 --- /dev/null +++ b/source/fs_test.go @@ -0,0 +1,55 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !integration + +package source + +import ( + "context" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/require" + "gopherly.dev/synthra/codec" +) + +func TestFileFS_Load_YAML(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "app.yaml": &fstest.MapFile{Data: []byte("port: 4242\n")}, + } + src := NewFileFS(fsys, "app.yaml", codec.YAML) + m, err := src.Load(context.Background()) + require.NoError(t, err) + require.EqualValues(t, 4242, m["port"]) +} + +func TestFileFS_Load_nilFS(t *testing.T) { + t.Parallel() + + src := NewFileFS(nil, "x.yaml", codec.YAML) + _, err := src.Load(context.Background()) + require.Error(t, err) +} + +func TestFileFS_Load_missingFile(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{} + src := NewFileFS(fsys, "missing.yaml", codec.YAML) + _, err := src.Load(context.Background()) + require.Error(t, err) +} diff --git a/source/map.go b/source/map.go new file mode 100644 index 0000000..bf0b177 --- /dev/null +++ b/source/map.go @@ -0,0 +1,42 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package source + +import "context" + +// Map is a Source backed by an in-memory map. +// +// NewMap aliases the caller's map for efficiency; callers must not mutate +// that map after passing it to NewMap if Load may run concurrently or if +// they rely on a stable snapshot. To pass an independent copy, use +// [maps.Clone] (or another deep copy strategy) before calling NewMap. +// +// Each successful Load returns the same map reference. +type Map struct { + conf map[string]any +} + +// NewMap returns a Source that always loads from m. A nil m is treated as empty. +func NewMap(m map[string]any) *Map { + if m == nil { + m = map[string]any{} + } + return &Map{conf: m} +} + +// Load returns the configured map. +func (s *Map) Load(context.Context) (map[string]any, error) { + return s.conf, nil +} diff --git a/source/map_test.go b/source/map_test.go new file mode 100644 index 0000000..ca695d0 --- /dev/null +++ b/source/map_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !integration + +package source + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewMap_nilReturnsEmpty(t *testing.T) { + t.Parallel() + m := NewMap(nil) + got, err := m.Load(context.Background()) + require.NoError(t, err) + require.NotNil(t, got) + require.Empty(t, got) +} + +func TestNewMap_returnsSameReference(t *testing.T) { + t.Parallel() + data := map[string]any{"k": "v"} + m := NewMap(data) + got1, err1 := m.Load(context.Background()) + require.NoError(t, err1) + got2, err2 := m.Load(context.Background()) + require.NoError(t, err2) + require.Equal(t, data, got1) + require.Equal(t, got1, got2) +} + +func TestNewMap_concurrentLoad_readOnly(t *testing.T) { + t.Parallel() + m := NewMap(map[string]any{"x": 1}) + var wg sync.WaitGroup + for range 32 { + wg.Go(func() { + got, err := m.Load(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, got["x"]) + }) + } + wg.Wait() +} diff --git a/synthra.go b/synthra.go new file mode 100644 index 0000000..cdb7cc3 --- /dev/null +++ b/synthra.go @@ -0,0 +1,1361 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package synthra + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "maps" + "math/rand" + "os" + "reflect" + "strings" + "sync" + "time" + + "dario.cat/mergo" + "github.com/go-viper/mapstructure/v2" + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/spf13/cast" + "gopherly.dev/synthra/codec" + "gopherly.dev/synthra/dumper" + "gopherly.dev/synthra/source" +) + +// Option is a functional option that can be used to configure an Synthra instance. +// Options apply to an internal config struct; the constructor validates +// and builds the public Synthra from it. +// Options must not be nil; passing nil results in a validation error at +// construction. +type Option func(cfg *config) + +// config holds construction-time configuration. Options mutate config; +// New() validates and builds Synthra from it. +type config struct { + sources []Source + dumpers []Dumper + binding any + tagName string + jsonSchemaCompiled *jsonschema.Schema + customValidators []func(map[string]any) error + validationErrors []error +} + +// Synthra manages configuration data loaded from multiple sources. +// It provides thread-safe access to configuration values and supports +// binding to structs, validation, and dumping to files. +// +// Synthra is the runtime object returned by New/MustNew; use it for +// Load, Get, and Dump. +// Synthra is safe for concurrent use by multiple goroutines. +type Synthra struct { + values *map[string]any + sources []Source + dumpers []Dumper + binding any + tagName string // Custom struct tag name (default: "synthra") + mu sync.RWMutex + jsonSchemaCompiled *jsonschema.Schema + customValidators []func(map[string]any) error + // decoderConfig holds the cached decoder configuration for struct binding + decoderConfig *mapstructure.DecoderConfig + decoderOnce sync.Once +} + +// WithSource adds a source to the configuration loader. +func WithSource(loader Source) Option { + return func(cfg *config) { + if loader == nil { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, "WithSource", errors.New("source cannot be nil"))) + return + } + cfg.sources = append(cfg.sources, loader) + } +} + +// WithIf returns an Option that applies the provided options only when +// condition is true. +// When condition is false, this option is a no-op. +// +// Example: +// +// cfg := synthra.MustNew( +// synthra.WithFile("config.yaml"), +// synthra.WithIf(os.Getenv("CONSUL_HTTP_ADDR") != "", +// synthra.WithConsul("production/service.yaml"), +// ), +// ) +func WithIf(condition bool, opts ...Option) Option { + return func(cfg *config) { + if !condition { + return + } + for _, opt := range opts { + opt(cfg) + } + } +} + +// WithFileDumper returns an Option that configures the Synthra instance +// to dump configuration data to a file. +// The format is automatically detected from the file extension (.yaml, +// .yml, .json, .toml). +// For files without extensions or custom formats, use WithFileDumperAs instead. +// +// Paths support environment variable expansion using ${VAR} or $VAR syntax. +// Example: "${LOG_DIR}/config.yaml" expands to "/var/log/config.yaml" +// when LOG_DIR=/var/log +// +// Example: +// +// cfg := synthra.MustNew( +// synthra.WithFile("config.yaml"), +// synthra.WithFileDumper("output.yaml"), // Auto-detects YAML +// ) +func WithFileDumper(path string) Option { + return func(cfg *config) { + path = os.ExpandEnv(path) + + c, err := detectFormat(path) + if err != nil { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, "WithFileDumper", err)) + return + } + + cfg.dumpers = append(cfg.dumpers, dumper.NewFile(path, c)) + } +} + +// WithDumper adds a dumper to the configuration loader. +func WithDumper(d Dumper) Option { + return func(cfg *config) { + if d == nil { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, "WithDumper", errors.New("dumper cannot be nil"))) + return + } + cfg.dumpers = append(cfg.dumpers, d) + } +} + +// WithFile returns an Option that configures the Synthra instance to +// load configuration data from a file. +// The format is automatically detected from the file extension (.yaml, +// .yml, .json, .toml). +// For files without extensions or custom formats, use WithFileAs instead. +// +// Paths support environment variable expansion using ${VAR} or $VAR syntax. +// Example: "${CONFIG_DIR}/app.yaml" expands to "/etc/myapp/app.yaml" +// when CONFIG_DIR=/etc/myapp +// +// Example: +// +// cfg := synthra.MustNew( +// synthra.WithFile("config.yaml"), // Automatically detects YAML +// synthra.WithFile("override.json"), // Automatically detects JSON +// ) +func WithFile(path string) Option { + return func(cfg *config) { + path = os.ExpandEnv(path) + + c, err := detectFormat(path) + if err != nil { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, "WithFile", err)) + return + } + + cfg.sources = append(cfg.sources, source.NewFile(path, c)) + } +} + +// WithFileFS returns an Option that loads configuration from path inside fsys. +// The format is detected from path's file extension, like [WithFile]. +// Paths support environment variable expansion using ${VAR} or $VAR syntax. +// +// If fsys is nil, New returns a validation error at construction. +// +// Example (tests with [testing/fstest.MapFS]): +// +// fsys := fstest.MapFS{"app.yaml": &fstest.MapFile{Data: []byte("port: 8080\n")}} +// cfg := synthra.MustNew(synthra.WithFileFS(fsys, "app.yaml")) +func WithFileFS(fsys fs.FS, path string) Option { + return func(cfg *config) { + if fsys == nil { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, "WithFileFS", errors.New("filesystem cannot be nil"))) + return + } + + path = os.ExpandEnv(path) + + c, err := detectFormat(path) + if err != nil { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, "WithFileFS", err)) + return + } + + cfg.sources = append(cfg.sources, source.NewFileFS(fsys, path, c)) + } +} + +// WithFileFSAs returns an Option that loads configuration from path inside fsys +// using an explicit decoder, like [WithFileAs]. +// Paths support environment variable expansion using ${VAR} or $VAR syntax. +// +// If fsys is nil, New returns a validation error at construction. +func WithFileFSAs(fsys fs.FS, path string, decoder codec.Decoder) Option { + return func(cfg *config) { + if fsys == nil { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, "WithFileFSAs", errors.New("filesystem cannot be nil"))) + return + } + + path = os.ExpandEnv(path) + cfg.sources = append(cfg.sources, source.NewFileFS(fsys, path, decoder)) + } +} + +// WithEnv returns an Option that configures the Synthra instance to load +// configuration data from environment variables. +// The prefix parameter specifies the prefix for the environment variables +// to be loaded. +// Environment variables are converted to lowercase and underscores create +// nested structures. +// +// Example: +// +// cfg := synthra.MustNew( +// synthra.WithFile("config.yaml"), +// synthra.WithEnv("APP_"), // Loads APP_SERVER_PORT as server.port +// ) +func WithEnv(prefix string) Option { + return func(cfg *config) { + cfg.sources = append(cfg.sources, source.NewOSEnvVar(prefix)) + } +} + +// WithConsul returns an Option that configures the Synthra instance to +// load configuration data from a Consul server. +// The format is automatically detected from the path extension. +// For custom formats, use WithConsulAs instead. +// +// CONSUL_HTTP_ADDR is required. If it is not set, New/MustNew returns a +// validation error at construction. +// For conditional Consul (e.g., development without Consul), wrap this +// option with WithIf. +// +// Paths support environment variable expansion using ${VAR} or $VAR syntax. +// Example: "${APP_ENV}/service.yaml" expands to "production/service.yaml" +// when APP_ENV=production +// +// Required environment variables: +// - CONSUL_HTTP_ADDR: The address of the Consul server +// (e.g., "http://localhost:8500") +// - CONSUL_HTTP_TOKEN: The access token for authentication with Consul +// (optional) +// +// Example: +// +// cfg := synthra.MustNew( +// synthra.WithConsul("production/service.yaml"), // Fails at construction if CONSUL_HTTP_ADDR is unset +// ) +func WithConsul(path string) Option { + return func(cfg *config) { + c, err := detectFormat(path) + if err != nil { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, "WithConsul", err)) + return + } + addConsulSource(cfg, "WithConsul", path, c) + } +} + +// WithFileAs returns an Option that configures the Synthra instance to +// load configuration data from a file with explicit decoder. +// Use this when the file doesn't have an extension or when you need to +// override the format detection. +// +// Paths support environment variable expansion using ${VAR} or $VAR syntax. +// Example: "${CONFIG_DIR}/app" expands to "/etc/myapp/app" when +// CONFIG_DIR=/etc/myapp +// +// Example: +// +// cfg := synthra.MustNew( +// synthra.WithFileAs("config", codec.YAML()), // No extension, specify YAML +// synthra.WithFileAs("config.dat", codec.JSON()), // Wrong extension, specify JSON +// ) +func WithFileAs(path string, decoder codec.Decoder) Option { + return func(cfg *config) { + path = os.ExpandEnv(path) + cfg.sources = append(cfg.sources, source.NewFile(path, decoder)) + } +} + +// WithConsulAs returns an Option that configures the Synthra instance to +// load configuration data from a Consul server with explicit decoder. +// Use this when you need to override the format detection. +// +// CONSUL_HTTP_ADDR is required. If it is not set, New/MustNew returns a +// validation error at construction. +// For conditional Consul (e.g., development without Consul), wrap this +// option with WithIf. +// +// Paths support environment variable expansion using ${VAR} or $VAR syntax. +// Example: "${APP_ENV}/service" expands to "production/service" when +// APP_ENV=production +// +// Required environment variables: +// - CONSUL_HTTP_ADDR: The address of the Consul server +// (e.g., "http://localhost:8500") +// - CONSUL_HTTP_TOKEN: The access token for authentication with Consul +// (optional) +// +// Example: +// +// cfg := synthra.MustNew( +// synthra.WithConsulAs("production/service", codec.JSON()), +// ) +func WithConsulAs(path string, decoder codec.Decoder) Option { + return func(cfg *config) { + addConsulSource(cfg, "WithConsulAs", path, decoder) + } +} + +func addConsulSource(cfg *config, opName, path string, decoder codec.Decoder) { + if os.Getenv("CONSUL_HTTP_ADDR") == "" { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, opName, errors.New("CONSUL_HTTP_ADDR is not set"))) + return + } + + path = os.ExpandEnv(path) + + l, err := source.NewConsul(path, decoder, nil) + if err != nil { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, opName, err)) + return + } + + cfg.sources = append(cfg.sources, l) +} + +// WithContent returns an Option that configures the Synthra instance to +// load configuration data from a byte slice. +// The decoder parameter specifies how to decode the data (e.g., +// codec.YAML(), codec.JSON()). +// +// Example: +// +// yamlContent := []byte("server:\n port: 8080") +// cfg := synthra.MustNew( +// synthra.WithContent(yamlContent, codec.YAML()), +// ) +func WithContent(data []byte, decoder codec.Decoder) Option { + return func(cfg *config) { + cfg.sources = append(cfg.sources, source.NewFileContent(data, decoder)) + } +} + +// WithFileDumperAs returns an Option that configures the Synthra instance +// to dump configuration data to a file with explicit encoder. +// Use this when the file doesn't have an extension or when you need to +// override the format detection. +// +// Paths support environment variable expansion using ${VAR} or $VAR syntax. +// Example: "${OUTPUT_DIR}/config" expands to "/tmp/config" when OUTPUT_DIR=/tmp +// +// Example: +// +// cfg := synthra.MustNew( +// synthra.WithFile("config.yaml"), +// synthra.WithFileDumperAs("output", codec.YAML()), // No extension, specify YAML +// ) +func WithFileDumperAs(path string, encoder codec.Encoder) Option { + return func(cfg *config) { + path = os.ExpandEnv(path) + cfg.dumpers = append(cfg.dumpers, dumper.NewFile(path, encoder)) + } +} + +// WithBinding returns an Option that configures the Synthra instance to +// bind configuration data to a struct. +func WithBinding(v any) Option { + return func(cfg *config) { + if v == nil { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, "WithBinding", errors.New("binding target cannot be nil"))) + return + } + if reflect.TypeOf(v).Kind() != reflect.Pointer { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, "WithBinding", errors.New("binding target must be a pointer"))) + return + } + cfg.binding = v + } +} + +// WithTag sets a custom struct tag name for binding (default: "synthra"). +// Use it when the default tag clashes with another convention or you want +// a shorter key (for example "cfg" or "config"). +// +// Example: +// +// type AppConfig struct { +// Port int `cfg:"port"` // Using custom tag +// } +// +// cfg := synthra.MustNew( +// synthra.WithFile("config.yaml"), +// synthra.WithBinding(&appConfig), +// synthra.WithTag("cfg"), +// ) +func WithTag(tagName string) Option { + return func(cfg *config) { + if tagName == "" { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, "WithTag", errors.New("tag name cannot be empty"))) + return + } + cfg.tagName = tagName + } +} + +// WithJSONSchema adds a JSON Schema for validation. +func WithJSONSchema(schema []byte) Option { + return func(cfg *config) { + // Use a unique schema name to avoid caching issues + //nolint:gosec // rand.Int() is used for a unique schema name, not security sensitive + schemaName := fmt.Sprintf("inline_%d.json", rand.Int()) + compiler := jsonschema.NewCompiler() + + jsonSchema, err := jsonschema.UnmarshalJSON(bytes.NewReader(schema)) + if err != nil { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, "WithJSONSchema", err)) + return + } + + if err = compiler.AddResource(schemaName, jsonSchema); err != nil { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, "WithJSONSchema", err)) + return + } + s, err := compiler.Compile(schemaName) + if err != nil { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, "WithJSONSchema", err)) + return + } + cfg.jsonSchemaCompiled = s + } +} + +// WithValidator adds a custom validation function. +func WithValidator(fn func(map[string]any) error) Option { + return func(cfg *config) { + if fn == nil { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, "WithValidator", errors.New("validator cannot be nil"))) + return + } + cfg.customValidators = append(cfg.customValidators, fn) + } +} + +// validate reports any errors collected during option application. +func (cfg *config) validate() error { + if len(cfg.validationErrors) == 0 { + return nil + } + return errors.Join(cfg.validationErrors...) +} + +// defaultConfig returns a config with default values. +func defaultConfig() *config { + return &config{ + sources: []Source{}, + tagName: "synthra", + } +} + +// configFromConfig builds a Synthra from a validated config. +func configFromConfig(cfg *config) *Synthra { + return &Synthra{ + values: &map[string]any{}, + sources: cfg.sources, + dumpers: cfg.dumpers, + binding: cfg.binding, + tagName: cfg.tagName, + jsonSchemaCompiled: cfg.jsonSchemaCompiled, + customValidators: cfg.customValidators, + } +} + +// New creates a new Synthra instance with the provided options. +// Options are applied to an internal config; after validation, the public +// Synthra is built from it. +// Options are applied in order; validation errors are collected and +// reported after all options are applied, so callers never receive a +// partially-initialized config. Options must not be nil— +// passing a nil option results in a validation error. Use MustNew for +// main() or when panic on error is acceptable. +func New(opts ...Option) (*Synthra, error) { + cfg := defaultConfig() + for i, opt := range opts { + if opt == nil { + cfg.validationErrors = append(cfg.validationErrors, NewConfigError(OpNew, fmt.Sprintf("option[%d]", i), errors.New("cannot be nil"))) + continue + } + opt(cfg) + } + if err := cfg.validate(); err != nil { + return nil, err + } + return configFromConfig(cfg), nil +} + +// MustNew creates a new Synthra instance with the provided options. +// It panics if validation fails after applying options. +// Use this in main() or initialization code where panic is acceptable. +// For cases where error handling is needed, use New() instead. +func MustNew(opts ...Option) *Synthra { + cfg, err := New(opts...) + if err != nil { + panic(fmt.Sprintf("synthra: validation failed: %v", err)) + } + return cfg +} + +// Validator is an interface for structs that can validate their own configuration. +// The validation package uses the same contract (validation.Validator); a +// type implementing either satisfies both. +type Validator interface { + Validate() error +} + +// applyDefaults applies default values from struct tags to a struct. +// It walks through the struct fields and sets defaults for fields that +// have the 'default' tag and are currently zero-valued. +func applyDefaults(target any) error { + val := reflect.ValueOf(target) + if val.Kind() != reflect.Pointer { + return fmt.Errorf("target must be a pointer") + } + + val = val.Elem() + if val.Kind() != reflect.Struct { + return fmt.Errorf("target must be a pointer to a struct") + } + + return setDefaults(val) +} + +// setDefaults recursively sets default values on a struct. +func setDefaults(val reflect.Value) error { + typ := val.Type() + + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := typ.Field(i) + + // Skip unexported fields + if !field.CanSet() { + continue + } + + // Handle nested structs + if field.Kind() == reflect.Struct { + if err := setDefaults(field); err != nil { + return err + } + continue + } + + // Check if field has a default tag + defaultTag := fieldType.Tag.Get("default") + if defaultTag == "" { + continue + } + + // Only set default if field is zero-valued + if !isZeroValue(field) { + continue + } + + // Set the default value based on field type + if err := setDefaultValue(field, defaultTag); err != nil { + return fmt.Errorf("failed to set default for field %s: %w", fieldType.Name, err) + } + } + + return nil +} + +// isZeroValue checks if a [reflect.Value] is the zero value for its type. +func isZeroValue(v reflect.Value) bool { + switch v.Kind() { + case reflect.Array, reflect.Map, reflect.Slice, reflect.String: + return v.Len() == 0 + case reflect.Bool: + return !v.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Interface, reflect.Pointer: + return v.IsNil() + default: + return false + } +} + +// setDefaultValue sets a default value on a field based on its type. +func setDefaultValue(field reflect.Value, defaultVal string) error { + switch field.Kind() { + case reflect.String: + field.SetString(defaultVal) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + // Special handling for time.Duration + if field.Type() == reflect.TypeFor[time.Duration]() { + d, err := time.ParseDuration(defaultVal) + if err != nil { + return err + } + field.SetInt(int64(d)) + } else { + i, err := cast.ToInt64E(defaultVal) + if err != nil { + return err + } + field.SetInt(i) + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + u, err := cast.ToUint64E(defaultVal) + if err != nil { + return err + } + field.SetUint(u) + case reflect.Float32, reflect.Float64: + f, err := cast.ToFloat64E(defaultVal) + if err != nil { + return err + } + field.SetFloat(f) + case reflect.Bool: + b, err := cast.ToBoolE(defaultVal) + if err != nil { + return err + } + field.SetBool(b) + default: + return fmt.Errorf("unsupported type for default tag: %s", field.Kind()) + } + return nil +} + +// getDecoderConfig returns a cached decoder configuration to reduce +// reflection overhead. +func (c *Synthra) getDecoderConfig() *mapstructure.DecoderConfig { + c.decoderOnce.Do(func() { + tagName := c.tagName + if tagName == "" { + tagName = "synthra" // Fallback to default + } + c.decoderConfig = &mapstructure.DecoderConfig{ + TagName: tagName, + Squash: true, + WeaklyTypedInput: true, + DecodeHook: mapstructure.ComposeDecodeHookFunc( + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToSliceHookFunc(","), + mapstructure.StringToTimeHookFunc(time.RFC3339), + mapstructure.StringToURLHookFunc(), + ), + } + }) + return c.decoderConfig +} + +// normalizeMapKeys recursively converts all map keys to lowercase for +// case-insensitive merging +func normalizeMapKeys(m map[string]any) map[string]any { + if m == nil { + return nil + } + normalized := make(map[string]any) + for k, v := range m { + lowerKey := strings.ToLower(k) + if nestedMap, ok := v.(map[string]any); ok { + normalized[lowerKey] = normalizeMapKeys(nestedMap) + } else { + normalized[lowerKey] = v + } + } + return normalized +} + +// loadSourcesSequential loads configuration data from all sources +// sequentially to avoid race conditions. +func (c *Synthra) loadSourcesSequential(ctx context.Context) (map[string]any, error) { + if len(c.sources) == 0 { + return make(map[string]any), nil + } + + // Merge to maintain precedence + newValues := make(map[string]any) + for i, src := range c.sources { + if ctx.Err() != nil { + return nil, NewConfigError(OpLoad, fmt.Sprintf("source[%d]", i), ctx.Err()) + } + + conf, err := src.Load(ctx) + if err != nil { + return nil, NewConfigError(OpLoad, fmt.Sprintf("source[%d]", i), err) + } + + // Ensure we always have a valid map, even if source returns nil + if conf == nil { + conf = make(map[string]any) + } + + // Normalize keys to lowercase for case-insensitive merging + normalizedConf := normalizeMapKeys(conf) + + // Use mergo to merge configuration maps with override behavior + if err = mergo.Map(&newValues, normalizedConf, mergo.WithOverride); err != nil { + return nil, NewConfigError(OpLoad, fmt.Sprintf("source[%d]", i), err) + } + } + + return newValues, nil +} + +// Load loads configuration data from the registered sources and merges it +// into the internal values map. The method validates the configuration data +// before atomically updating the internal state. +// Load is safe to call concurrently. +// +// Errors: +// - Returns [*ConfigError] with [OpLoad] if ctx is nil ([ErrNilContext]) +// - Returns [*ConfigError] with [OpLoad] if any source fails to load or merge +// - Returns [*ConfigError] with [OpLoad] and Path "json-schema" if JSON schema +// validation fails +// - Returns [*ConfigError] with [OpLoad] if custom validators fail +// - Returns [*ConfigError] with [OpLoad] if binding or struct validation fails +func (c *Synthra) Load(ctx context.Context) error { + if ctx == nil { + return NewConfigError(OpLoad, "", ErrNilContext) + } + + newValues, err := c.loadSourcesSequential(ctx) + if err != nil { + return err + } + + // Ensure newValues is never nil + if newValues == nil { + newValues = make(map[string]any) + } + + if c.jsonSchemaCompiled != nil { + if err = c.jsonSchemaCompiled.Validate(newValues); err != nil { + return NewConfigError(OpLoad, "json-schema", err) + } + } + + // Custom function validators + for i, fn := range c.customValidators { + var validatorErr error + func() { + defer func() { + if r := recover(); r != nil { + if rerr, ok := r.(error); ok { + validatorErr = fmt.Errorf("validator panic: %w", rerr) + } else { + validatorErr = fmt.Errorf("validator panic: %v", r) + } + } + }() + validatorErr = fn(newValues) + }() + if validatorErr != nil { + return NewConfigError(OpLoad, fmt.Sprintf("custom-validator[%d]", i), validatorErr) + } + } + + c.mu.Lock() + defer c.mu.Unlock() + + if c.binding != nil { + bindingType := reflect.TypeOf(c.binding) + if bindingType.Kind() == reflect.Pointer { + bindingType = bindingType.Elem() + } + tempBinding := reflect.New(bindingType).Interface() + + if bindErr := c.decodeBindingInto(tempBinding, &newValues); bindErr != nil { + return NewConfigError(OpLoad, "binding-decode", bindErr) + } + if bindErr := applyDefaults(tempBinding); bindErr != nil { + return NewConfigError(OpLoad, "binding-defaults", bindErr) + } + if v, ok := tempBinding.(Validator); ok { + if validateErr := v.Validate(); validateErr != nil { + return NewConfigError(OpLoad, "binding-validate", validateErr) + } + } + + if bindErr := c.decodeBindingInto(c.binding, &newValues); bindErr != nil { + return NewConfigError(OpLoad, "binding-decode", bindErr) + } + if bindErr := applyDefaults(c.binding); bindErr != nil { + return NewConfigError(OpLoad, "binding-defaults", bindErr) + } + } + + c.values = &newValues + + return nil +} + +// Dump writes the current configuration values to the registered dumpers. +// +// Errors: +// - Returns [*ConfigError] with [OpDump] if ctx is nil ([ErrNilContext]) +// - Returns [*ConfigError] with [OpDump] if any dumper fails to write the +// configuration +func (c *Synthra) Dump(ctx context.Context) error { + if ctx == nil { + return NewConfigError(OpDump, "", ErrNilContext) + } + + // Get a copy of the values to avoid holding locks during dumper calls + var valuesCopy map[string]any + func() { + c.mu.RLock() + defer c.mu.RUnlock() + if c.values != nil { + // Use shallow copy for better performance + valuesCopy = make(map[string]any, len(*c.values)) + maps.Copy(valuesCopy, *c.values) + } else { + valuesCopy = make(map[string]any) + } + }() + + for i, d := range c.dumpers { + if err := d.Dump(ctx, &valuesCopy); err != nil { + return NewConfigError(OpDump, fmt.Sprintf("dumper[%d]", i), err) + } + } + + return nil +} + +// decodeBindingInto decodes values into target using mapstructure. Errors +// match the messages produced by the former bind/bindAndValidate helpers. +func (c *Synthra) decodeBindingInto(target, values any) error { + decoderCfg := c.getDecoderConfig() + decoderCfg.Result = target + + decoder, err := mapstructure.NewDecoder(decoderCfg) + if err != nil { + return fmt.Errorf("failed to create decoder: %w", err) + } + + if err = decoder.Decode(values); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + return nil +} + +// Values returns a pointer to a shallow copy of the loaded configuration map. +// The copy is taken while holding a read lock; nested maps, slices, and +// pointers inside values are not deep-copied, so mutating nested data still +// affects the same objects held by this Synthra. +// If Load has not run yet, it returns a pointer to a new empty map. +func (c *Synthra) Values() *map[string]any { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.values == nil { + m := make(map[string]any) + return &m + } + + cloned := maps.Clone(*c.values) + return &cloned +} + +// getValueFromMap retrieves the value associated with the given path from +// the internal values map. The path is a dot-separated string that +// represents the nested structure of the map. If the path is valid and +// the final value is found, it is returned. Otherwise, nil is returned. +// Keys are case-insensitive since they are stored in lowercase. +func (c *Synthra) getValueFromMap(path string) any { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.values == nil { + return nil + } + + // Work with a copy of the current map to avoid race conditions during traversal + current := *c.values + + // Normalize the path to lowercase for case-insensitive lookup + normalizedPath := strings.ToLower(path) + + // 1. Check for direct key match first + if val, ok := current[normalizedPath]; ok { + return val + } + + // 2. Fallback to dot notation traversal + segments := strings.Split(normalizedPath, ".") + for i, segment := range segments { + if currentMap, ok := current[segment]; ok { + if i == len(segments)-1 { + return currentMap + } + if nestedMap, isMap := currentMap.(map[string]any); isMap { + current = nestedMap + } else { + return nil + } + } else { + return nil + } + } + return nil +} + +// requireValue returns the raw value at key for strict typed accessors and [Get]. +// It returns [ErrNilConfig] if c is nil, and an error wrapping [ErrKeyNotFound] +// if the key is empty or not present. +func (c *Synthra) requireValue(key string) (any, error) { + if c == nil { + return nil, ErrNilConfig + } + if key == "" { + return nil, fmt.Errorf("%w: empty key", ErrKeyNotFound) + } + v := c.getValueFromMap(key) + if v == nil { + return nil, fmt.Errorf("%w: %q", ErrKeyNotFound, key) + } + return v, nil +} + +// Get returns the value associated with the given key as an any type. +// If the key is not found, it returns nil. +func (c *Synthra) Get(key string) any { + if c == nil { + return nil + } + if key == "" { + return nil + } + return c.getValueFromMap(key) +} + +// String returns the value at key as a string. +// It returns an error if c is nil, the key is missing, +// or the value cannot be converted. +// +// Example: +// +// host, err := cfg.String("server.host") +// if err != nil { +// return err +// } +func (c *Synthra) String(key string) (string, error) { + v, err := c.requireValue(key) + if err != nil { + return "", err + } + s, err := cast.ToStringE(v) + if err != nil { + return "", NewConfigError(OpGet, key, err) + } + return s, nil +} + +// Int returns the value at key as an int. +// It returns an error if c is nil, the key is missing, +// or the value cannot be converted. +// +// Example: +// +// port, err := cfg.Int("server.port") +// if err != nil { +// return err +// } +func (c *Synthra) Int(key string) (int, error) { + v, err := c.requireValue(key) + if err != nil { + return 0, err + } + i, err := cast.ToIntE(v) + if err != nil { + return 0, NewConfigError(OpGet, key, err) + } + return i, nil +} + +// Int64 returns the value at key as an int64. +// It returns an error if c is nil, the key is missing, +// or the value cannot be converted. +// +// Example: +// +// maxSize, err := cfg.Int64("max_size") +// if err != nil { +// return err +// } +func (c *Synthra) Int64(key string) (int64, error) { + v, err := c.requireValue(key) + if err != nil { + return 0, err + } + i, err := cast.ToInt64E(v) + if err != nil { + return 0, NewConfigError(OpGet, key, err) + } + return i, nil +} + +// Float64 returns the value at key as a float64. +// It returns an error if c is nil, the key is missing, +// or the value cannot be converted. +// +// Example: +// +// rate, err := cfg.Float64("rate") +// if err != nil { +// return err +// } +func (c *Synthra) Float64(key string) (float64, error) { + v, err := c.requireValue(key) + if err != nil { + return 0, err + } + f, err := cast.ToFloat64E(v) + if err != nil { + return 0, NewConfigError(OpGet, key, err) + } + return f, nil +} + +// Bool returns the value at key as a bool. +// It returns an error if c is nil, the key is missing, +// or the value cannot be converted. +// +// Example: +// +// debug, err := cfg.Bool("debug") +// if err != nil { +// return err +// } +func (c *Synthra) Bool(key string) (bool, error) { + v, err := c.requireValue(key) + if err != nil { + return false, err + } + b, err := cast.ToBoolE(v) + if err != nil { + return false, NewConfigError(OpGet, key, err) + } + return b, nil +} + +// Duration returns the value at key as a [time.Duration]. +// It returns an error if c is nil, the key is missing, +// or the value cannot be converted. +// +// Example: +// +// timeout, err := cfg.Duration("timeout") +// if err != nil { +// return err +// } +func (c *Synthra) Duration(key string) (time.Duration, error) { + v, err := c.requireValue(key) + if err != nil { + return 0, err + } + d, err := cast.ToDurationE(v) + if err != nil { + return 0, NewConfigError(OpGet, key, err) + } + return d, nil +} + +// Time returns the value at key as a [time.Time]. +// It returns an error if c is nil, the key is missing, +// or the value cannot be converted. +// +// Example: +// +// startTime, err := cfg.Time("start_time") +// if err != nil { +// return err +// } +func (c *Synthra) Time(key string) (time.Time, error) { + v, err := c.requireValue(key) + if err != nil { + return time.Time{}, err + } + tm, err := cast.ToTimeE(v) + if err != nil { + return time.Time{}, NewConfigError(OpGet, key, err) + } + return tm, nil +} + +// StringSlice returns the value at key as a []string. +// It returns an error if c is nil, the key is missing, +// or the value cannot be converted. +// +// Example: +// +// tags, err := cfg.StringSlice("tags") +// if err != nil { +// return err +// } +func (c *Synthra) StringSlice(key string) ([]string, error) { + v, err := c.requireValue(key) + if err != nil { + return nil, err + } + s, err := cast.ToStringSliceE(v) + if err != nil { + return nil, NewConfigError(OpGet, key, err) + } + return s, nil +} + +// IntSlice returns the value at key as a []int. +// It returns an error if c is nil, the key is missing, +// or the value cannot be converted. +// +// Example: +// +// ports, err := cfg.IntSlice("ports") +// if err != nil { +// return err +// } +func (c *Synthra) IntSlice(key string) ([]int, error) { + v, err := c.requireValue(key) + if err != nil { + return nil, err + } + s, err := cast.ToIntSliceE(v) + if err != nil { + return nil, NewConfigError(OpGet, key, err) + } + return s, nil +} + +// StringMap returns the value at key as a map[string]any. +// It returns an error if c is nil, the key is missing, +// or the value cannot be converted. +// +// Example: +// +// metadata, err := cfg.StringMap("metadata") +// if err != nil { +// return err +// } +func (c *Synthra) StringMap(key string) (map[string]any, error) { + v, err := c.requireValue(key) + if err != nil { + return nil, err + } + m, err := cast.ToStringMapE(v) + if err != nil { + return nil, NewConfigError(OpGet, key, err) + } + return m, nil +} + +// StringOr returns the value associated with the given key as a string, +// or the default value if not found. +// +// Example: +// +// host := cfg.StringOr("server.host", "localhost") +func (c *Synthra) StringOr(key, defaultVal string) string { + if c == nil { + return defaultVal + } + val := c.Get(key) + if val == nil { + return defaultVal + } + return cast.ToString(val) +} + +// IntOr returns the value associated with the given key as an int, or +// the default value if not found. +// +// Example: +// +// port := cfg.IntOr("server.port", 8080) +func (c *Synthra) IntOr(key string, defaultVal int) int { + if c == nil { + return defaultVal + } + val := c.Get(key) + if val == nil { + return defaultVal + } + return cast.ToInt(val) +} + +// Int64Or returns the value associated with the given key as an int64, +// or the default value if not found. +// +// Example: +// +// maxSize := cfg.Int64Or("max_size", 1024) +func (c *Synthra) Int64Or(key string, defaultVal int64) int64 { + if c == nil { + return defaultVal + } + val := c.Get(key) + if val == nil { + return defaultVal + } + return cast.ToInt64(val) +} + +// Float64Or returns the value associated with the given key as a float64, +// or the default value if not found. +// +// Example: +// +// rate := cfg.Float64Or("rate", 0.5) +func (c *Synthra) Float64Or(key string, defaultVal float64) float64 { + if c == nil { + return defaultVal + } + val := c.Get(key) + if val == nil { + return defaultVal + } + return cast.ToFloat64(val) +} + +// BoolOr returns the value associated with the given key as a boolean, +// or the default value if not found. +// +// Example: +// +// debug := cfg.BoolOr("debug", false) +func (c *Synthra) BoolOr(key string, defaultVal bool) bool { + if c == nil { + return defaultVal + } + val := c.Get(key) + if val == nil { + return defaultVal + } + return cast.ToBool(val) +} + +// DurationOr returns the value associated with the given key as a +// [time.Duration], or the default value if not found. +// +// Example: +// +// timeout := cfg.DurationOr("timeout", 30*time.Second) +func (c *Synthra) DurationOr(key string, defaultVal time.Duration) time.Duration { + if c == nil { + return defaultVal + } + val := c.Get(key) + if val == nil { + return defaultVal + } + return cast.ToDuration(val) +} + +// TimeOr returns the value associated with the given key as a [time.Time], +// or the default value if not found. +// +// Example: +// +// startTime := cfg.TimeOr("start_time", time.Now()) +func (c *Synthra) TimeOr(key string, defaultVal time.Time) time.Time { + if c == nil { + return defaultVal + } + val := c.Get(key) + if val == nil { + return defaultVal + } + return cast.ToTime(val) +} + +// StringSliceOr returns the value associated with the given key as a +// slice of strings, or the default value if not found. +// +// Example: +// +// tags := cfg.StringSliceOr("tags", []string{"default"}) +func (c *Synthra) StringSliceOr(key string, defaultVal []string) []string { + if c == nil { + return defaultVal + } + val := c.Get(key) + if val == nil { + return defaultVal + } + return cast.ToStringSlice(val) +} + +// IntSliceOr returns the value associated with the given key as a slice +// of integers, or the default value if not found. +// +// Example: +// +// ports := cfg.IntSliceOr("ports", []int{8080, 8081}) +func (c *Synthra) IntSliceOr(key string, defaultVal []int) []int { + if c == nil { + return defaultVal + } + val := c.Get(key) + if val == nil { + return defaultVal + } + return cast.ToIntSlice(val) +} + +// StringMapOr returns the value associated with the given key as a +// map[string]any, or the default value if not found. +// +// Example: +// +// metadata := cfg.StringMapOr("metadata", map[string]any{"version": "1.0"}) +func (c *Synthra) StringMapOr(key string, defaultVal map[string]any) map[string]any { + if c == nil { + return defaultVal + } + val := c.Get(key) + if val == nil { + return defaultVal + } + return cast.ToStringMap(val) +} diff --git a/synthra_test.go b/synthra_test.go new file mode 100644 index 0000000..1f5f927 --- /dev/null +++ b/synthra_test.go @@ -0,0 +1,2455 @@ +// Copyright 2026 The Gopherly Authors +// Copyright 2025 Company.info B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !integration + +package synthra + +import ( + "context" + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "sync" + "testing" + "testing/fstest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopherly.dev/synthra/codec" + "gopherly.dev/synthra/source" +) + +func mustString(t *testing.T, cfg *Synthra, key string) string { + t.Helper() + v, err := cfg.String(key) + require.NoError(t, err) + return v +} + +func mustInt(t *testing.T, cfg *Synthra, key string) int { + t.Helper() + v, err := cfg.Int(key) + require.NoError(t, err) + return v +} + +func mustBool(t *testing.T, cfg *Synthra, key string) bool { + t.Helper() + v, err := cfg.Bool(key) + require.NoError(t, err) + return v +} + +func mustInt64(t *testing.T, cfg *Synthra, key string) int64 { + t.Helper() + v, err := cfg.Int64(key) + require.NoError(t, err) + return v +} + +func mustFloat64(t *testing.T, cfg *Synthra, key string) float64 { + t.Helper() + v, err := cfg.Float64(key) + require.NoError(t, err) + return v +} + +func mustTime(t *testing.T, cfg *Synthra, key string) time.Time { + t.Helper() + v, err := cfg.Time(key) + require.NoError(t, err) + return v +} + +func mustDuration(t *testing.T, cfg *Synthra, key string) time.Duration { + t.Helper() + v, err := cfg.Duration(key) + require.NoError(t, err) + return v +} + +func mustIntSlice(t *testing.T, cfg *Synthra, key string) []int { + t.Helper() + v, err := cfg.IntSlice(key) + require.NoError(t, err) + return v +} + +func mustStringSlice(t *testing.T, cfg *Synthra, key string) []string { + t.Helper() + v, err := cfg.StringSlice(key) + require.NoError(t, err) + return v +} + +func mustStringMap(t *testing.T, cfg *Synthra, key string) map[string]any { + t.Helper() + v, err := cfg.StringMap(key) + require.NoError(t, err) + return v +} + +// loadTestConfig builds and loads a Synthra using source.NewMap(m). +// External tests should use gopherly.dev/synthra/synthratest.Load instead. +func loadTestConfig(t *testing.T, m map[string]any) *Synthra { + t.Helper() + cfg, err := New(WithSource(source.NewMap(m))) + require.NoError(t, err) + require.NoError(t, cfg.Load(t.Context())) + return cfg +} + +type errOnlySource struct{ err error } + +func (e errOnlySource) Load(context.Context) (map[string]any, error) { + return nil, e.err +} + +// recordingDumper records Dump calls for tests (see synthratest.Dumper). +// Defined here to avoid importing synthratest from these tests. +type recordingDumper struct { + Err error + + mu sync.Mutex + calls int + last map[string]any +} + +func (d *recordingDumper) Dump(_ context.Context, values *map[string]any) error { + d.mu.Lock() + defer d.mu.Unlock() + d.calls++ + if values != nil && *values != nil { + cp := make(map[string]any, len(*values)) + maps.Copy(cp, *values) + d.last = cp + } + return d.Err +} + +func (d *recordingDumper) Calls() int { + d.mu.Lock() + defer d.mu.Unlock() + return d.calls +} + +func (d *recordingDumper) Last() map[string]any { + d.mu.Lock() + defer d.mu.Unlock() + return d.last +} + +// validatingStruct is used by TestBinding_ValidatorInterface; implements +// Validator. +type validatingStruct struct { + Port int `synthra:"port"` +} + +func (v *validatingStruct) Validate() error { + if v.Port <= 0 { + return errors.New("port must be positive") + } + return nil +} + +// bindStruct is used by binding tests. +type bindStruct struct { + Foo string `synthra:"foo"` + Bar int `synthra:"bar"` +} + +func TestNew(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts []Option + wantErr bool + errMsg string + }{ + { + name: "no options succeeds", + opts: nil, + wantErr: false, + }, + { + name: "with valid source succeeds", + opts: []Option{WithSource(source.NewMap(map[string]any{"foo": "bar"}))}, + wantErr: false, + }, + { + name: "with nil source fails", + opts: []Option{WithSource(nil)}, + wantErr: true, + errMsg: "source cannot be nil", + }, + { + name: "with nil dumper fails", + opts: []Option{WithDumper(nil)}, + wantErr: true, + errMsg: "dumper cannot be nil", + }, + { + name: "with nil binding fails", + opts: []Option{WithBinding(nil)}, + wantErr: true, + errMsg: "binding target cannot be nil", + }, + { + name: "with non-pointer binding fails", + opts: []Option{ + WithSource(source.NewMap(map[string]any{"foo": "bar"})), + WithBinding(bindStruct{}), // not a pointer + }, + wantErr: true, + errMsg: "binding target must be a pointer", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg, err := New(tt.opts...) + + if tt.wantErr { + require.Error(t, err) + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg) + } + return + } + + require.NoError(t, err) + assert.NotNil(t, cfg) + }) + } +} + +func TestNew_multipleValidationErrors(t *testing.T) { + t.Parallel() + cfg, err := New( + WithSource(nil), + WithBinding(nil), + ) + require.Error(t, err) + require.Nil(t, cfg) + // Joined error should contain both validation messages + assert.Contains(t, err.Error(), "source cannot be nil") + assert.Contains(t, err.Error(), "binding target cannot be nil") +} + +func TestNew_WithTag(t *testing.T) { + t.Parallel() + + type cfgTagStruct struct { + Foo string `cfg:"foo"` + Bar int `cfg:"bar"` + } + + tests := []struct { + name string + opts []Option + wantErr bool + errMsg string + verify func(t *testing.T, cfg *Synthra) + }{ + { + name: "valid custom tag binds correctly", + opts: []Option{ + WithSource(source.NewMap(map[string]any{"foo": "baz", "bar": 99})), + WithTag("cfg"), + WithBinding(&cfgTagStruct{}), + }, + wantErr: false, + verify: func(t *testing.T, cfg *Synthra) { + t.Helper() + require.NoError(t, cfg.Load(context.Background())) + assert.Equal(t, "baz", mustString(t, cfg, "foo")) + assert.Equal(t, 99, mustInt(t, cfg, "bar")) + }, + }, + { + name: "empty tag name fails", + opts: []Option{WithTag("")}, + wantErr: true, + errMsg: "tag name cannot be empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg, err := New(tt.opts...) + + if tt.wantErr { + require.Error(t, err) + assert.ErrorContains(t, err, tt.errMsg) + return + } + + require.NoError(t, err) + assert.NotNil(t, cfg) + if tt.verify != nil { + tt.verify(t, cfg) + } + }) + } +} + +func TestNew_NilOptionFails(t *testing.T) { + t.Parallel() + + src1 := source.NewMap(map[string]any{"a": "1"}) + src2 := source.NewMap(map[string]any{"b": "2"}) + cfg, err := New(WithSource(src1), nil, WithSource(src2)) + require.Error(t, err) + require.Nil(t, cfg) + assert.Contains(t, err.Error(), "cannot be nil") + assert.Contains(t, err.Error(), "option[1]") +} + +func TestNew_NilValidatorFails(t *testing.T) { + t.Parallel() + + cfg, err := New(WithValidator(nil)) + require.Error(t, err) + require.Nil(t, cfg) + assert.ErrorContains(t, err, "validator cannot be nil") +} + +func TestMustNew_NilOptionPanics(t *testing.T) { + t.Parallel() + + src := source.NewMap(map[string]any{"a": "1"}) + var panicMsg string + func() { + defer func() { + if r := recover(); r != nil { + panicMsg = fmt.Sprint(r) + } + }() + MustNew(WithSource(src), nil) + }() + require.NotEmpty(t, panicMsg, "MustNew with nil option should panic") + assert.Contains(t, panicMsg, "cannot be nil") + assert.Contains(t, panicMsg, "option[1]") +} + +func TestNew_OptionErrorPaths(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opt Option + wantErr bool + errContains string + }{ + { + name: "WithFileDumper unknown extension", + opt: WithFileDumper("file.xyz"), + wantErr: true, + errContains: "cannot detect format", + }, + { + name: "WithFile unknown extension", + opt: WithFile("file.xyz"), + wantErr: true, + errContains: "cannot detect format", + }, + { + name: "WithFileFS nil filesystem", + opt: WithFileFS(nil, "a.yaml"), + wantErr: true, + errContains: "filesystem cannot be nil", + }, + { + name: "WithFileFS unknown extension", + opt: WithFileFS(fstest.MapFS{}, "file.xyz"), + wantErr: true, + errContains: "cannot detect format", + }, + { + name: "WithFileFSAs nil filesystem", + opt: WithFileFSAs(nil, "a.yaml", codec.YAML), + wantErr: true, + errContains: "filesystem cannot be nil", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg, err := New(tt.opt) + if tt.wantErr { + require.Error(t, err) + assert.ErrorContains(t, err, tt.errContains) + return + } + require.NoError(t, err) + assert.NotNil(t, cfg) + }) + } +} + +func TestWithFileFS_Load(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "app.yaml": &fstest.MapFile{Data: []byte("port: 4242\n")}, + } + cfg, err := New(WithFileFS(fsys, "app.yaml")) + require.NoError(t, err) + require.NoError(t, cfg.Load(context.Background())) + assert.Equal(t, 4242, mustInt(t, cfg, "port")) +} + +func TestWithFileFSAs_Load(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "cfg": &fstest.MapFile{Data: []byte("name: test\n")}, + } + cfg, err := New(WithFileFSAs(fsys, "cfg", codec.YAML)) + require.NoError(t, err) + require.NoError(t, cfg.Load(context.Background())) + assert.Equal(t, "test", mustString(t, cfg, "name")) +} + +func TestDetectFormat_UnknownExtension(t *testing.T) { + t.Parallel() + + _, err := detectFormat("file.xyz") + require.Error(t, err) + assert.ErrorContains(t, err, "cannot detect format from extension") + assert.ErrorContains(t, err, "WithFileAs()") +} + +func TestNew_WithConsul_OptionErrorPaths(t *testing.T) { + // Do not use t.Parallel() here: subtests use t.Setenv which is incompatible with parallel. + + t.Run("with CONSUL_HTTP_ADDR set unknown extension returns error", func(t *testing.T) { + t.Setenv("CONSUL_HTTP_ADDR", "http://localhost:8500") + + _, err := New(WithConsul("path/file.xyz")) + require.Error(t, err) + assert.ErrorContains(t, err, "cannot detect format") + }) +} + +func TestNew_MultipleOptionErrors(t *testing.T) { + t.Parallel() + + _, err := New(WithSource(nil), WithDumper(nil), WithBinding(nil)) + require.Error(t, err) + // Errors are joined; all should be present + assert.Contains(t, err.Error(), "source cannot be nil") + assert.Contains(t, err.Error(), "dumper cannot be nil") + assert.Contains(t, err.Error(), "binding target cannot be nil") +} + +func TestMustNew(t *testing.T) { + t.Parallel() + + t.Run("success with no options", func(t *testing.T) { + t.Parallel() + c := MustNew() + assert.NotNil(t, c) + }) + + t.Run("success with valid source", func(t *testing.T) { + t.Parallel() + src := source.NewMap(map[string]any{"foo": "bar"}) + c := MustNew(WithSource(src)) + assert.NotNil(t, c) + require.NoError(t, c.Load(context.Background())) + assert.Equal(t, "bar", mustString(t, c, "foo")) + }) + + t.Run("panics with nil source", func(t *testing.T) { + t.Parallel() + assert.Panics(t, func() { + MustNew(WithSource(nil)) + }) + }) + + t.Run("panic message contains config failure prefix", func(t *testing.T) { + t.Parallel() + var panicMsg string + func() { + defer func() { + if r := recover(); r != nil { + panicMsg = fmt.Sprint(r) + } + }() + MustNew(WithSource(nil)) + }() + require.Contains(t, panicMsg, "synthra: validation failed") + }) +} + +func TestLoad(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func() (*Synthra, error) + wantErr bool + errMsg string + }{ + { + name: "succeeds with valid source", + setup: func() (*Synthra, error) { + return New(WithSource(source.NewMap(map[string]any{"foo": "bar", "bar": 42}))) + }, + wantErr: false, + }, + { + name: "succeeds with no sources", + setup: func() (*Synthra, error) { + return New() + }, + wantErr: false, + }, + { + name: "succeeds with nil source map", + setup: func() (*Synthra, error) { + return New(WithSource(source.NewMap(nil))) + }, + wantErr: false, + }, + { + name: "error propagates from source", + setup: func() (*Synthra, error) { + return New(WithSource(errOnlySource{err: errors.New("fail")})) + }, + wantErr: true, + }, + { + name: "multiple sources merge correctly", + setup: func() (*Synthra, error) { + return New( + WithSource(source.NewMap(map[string]any{"foo": "bar", "bar": 1})), + WithSource(source.NewMap(map[string]any{"bar": 2, "baz": 3})), + ) + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg, err := tt.setup() + require.NoError(t, err, "setup should not fail") + + err = cfg.Load(context.Background()) + + if tt.wantErr { + require.Error(t, err) + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg) + } + return + } + + require.NoError(t, err) + }) + } +} + +func TestLoad_MultipleSources(t *testing.T) { + t.Parallel() + + src1 := source.NewMap(map[string]any{"foo": "bar", "bar": 1}) + src2 := source.NewMap(map[string]any{"bar": 2, "baz": 3}) + cfg, err := New(WithSource(src1), WithSource(src2)) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "bar", mustString(t, cfg, "foo")) + assert.Equal(t, 2, mustInt(t, cfg, "bar")) // src2 overrides src1 + assert.Equal(t, 3, mustInt(t, cfg, "baz")) +} + +func TestLoad_CancelledContext(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately so Load sees ctx.Err() != nil + + src := source.NewMap(map[string]any{"foo": "bar"}) + cfg, err := New(WithSource(src)) + require.NoError(t, err) + + err = cfg.Load(ctx) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestLoad_BindingPointerToNonStruct(t *testing.T) { + t.Parallel() + + var notAStruct int + cfg, err := New(WithSource(source.NewMap(map[string]any{"x": "y"})), WithBinding(¬AStruct)) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.Error(t, err) + // Binding to *int fails (decode expects struct, or applyDefaults rejects non-struct) +} + +func TestLoad_BindingInvalidDurationDefault(t *testing.T) { + t.Parallel() + + type withDuration struct { + Timeout time.Duration `synthra:"timeout" default:"not-a-duration"` + } + var target withDuration + cfg, err := New(WithSource(source.NewMap(map[string]any{})), WithBinding(&target)) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.Error(t, err) + assert.ErrorContains(t, err, "failed to set default") +} + +func TestLoad_BindingUnsupportedDefaultType(t *testing.T) { + t.Parallel() + + type withSliceDefault struct { + Items []string `synthra:"items" default:"a,b,c"` // slice default not supported by setDefaultValue + } + var target withSliceDefault + cfg, err := New(WithSource(source.NewMap(map[string]any{})), WithBinding(&target)) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + require.Error(t, err) + assert.ErrorContains(t, err, "unsupported type for default tag") +} + +func TestValues_WithoutLoad(t *testing.T) { + t.Parallel() + + cfg, err := New() + require.NoError(t, err) + + vals := cfg.Values() + require.NotNil(t, vals) + require.NotNil(t, *vals) + assert.Empty(t, *vals) +} + +func TestBinding(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + conf map[string]any + setupBind func() any + verify func(t *testing.T, target any) + wantErr bool + }{ + { + name: "basic binding succeeds", + conf: map[string]any{"foo": "bar", "bar": 42}, + setupBind: func() any { + return &bindStruct{} + }, + verify: func(t *testing.T, target any) { + t.Helper() + bind, isBind := target.(*bindStruct) + require.True(t, isBind) + assert.Equal(t, "bar", bind.Foo) + assert.Equal(t, 42, bind.Bar) + }, + wantErr: false, + }, + { + name: "binding with extra fields succeeds", + conf: map[string]any{"foo": "bar", "bar": 42, "extra": 99}, + setupBind: func() any { + return &bindStruct{} + }, + verify: func(t *testing.T, target any) { + t.Helper() + bind, isBind := target.(*bindStruct) + require.True(t, isBind) + assert.Equal(t, "bar", bind.Foo) + assert.Equal(t, 42, bind.Bar) + }, + wantErr: false, + }, + { + name: "binding with missing fields uses defaults", + conf: map[string]any{"foo": "bar"}, + setupBind: func() any { + return &bindStruct{} + }, + verify: func(t *testing.T, target any) { + t.Helper() + bind, isBind := target.(*bindStruct) + require.True(t, isBind) + assert.Equal(t, "bar", bind.Foo) + assert.Equal(t, 0, bind.Bar) + }, + wantErr: false, + }, + { + name: "binding with type mismatch fails", + conf: map[string]any{"foo": 123, "bar": "notanint"}, + setupBind: func() any { + return &bindStruct{} + }, + verify: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + target := tt.setupBind() + src := source.NewMap(tt.conf) + cfg, err := New(WithSource(src), WithBinding(target)) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + if tt.verify != nil { + tt.verify(t, target) + } + }) + } +} + +func TestBinding_DefaultTag(t *testing.T) { + t.Parallel() + + type defaultTagStruct struct { + Foo string `synthra:"foo" default:"defaultfoo"` + Bar int `synthra:"bar" default:"42"` + Enabled bool `synthra:"enabled" default:"true"` + Timeout time.Duration `synthra:"timeout" default:"5s"` + } + + tests := []struct { + name string + conf map[string]any + verify func(t *testing.T, target *defaultTagStruct) + }{ + { + name: "defaults applied when keys omitted", + conf: map[string]any{"foo": "fromconfig"}, + verify: func(t *testing.T, target *defaultTagStruct) { + t.Helper() + assert.Equal(t, "fromconfig", target.Foo) + assert.Equal(t, 42, target.Bar) + assert.True(t, target.Enabled) + assert.Equal(t, 5*time.Second, target.Timeout) + }, + }, + { + name: "provided values override defaults", + conf: map[string]any{"foo": "x", "bar": 7, "enabled": true, "timeout": "10s"}, + verify: func(t *testing.T, target *defaultTagStruct) { + t.Helper() + assert.Equal(t, "x", target.Foo) + assert.Equal(t, 7, target.Bar) + assert.True(t, target.Enabled) + assert.Equal(t, 10*time.Second, target.Timeout) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var target defaultTagStruct + cfg, err := New(WithSource(source.NewMap(tt.conf)), WithBinding(&target)) + require.NoError(t, err) + require.NoError(t, cfg.Load(context.Background())) + tt.verify(t, &target) + }) + } +} + +func TestBinding_ValidatorInterface(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + conf map[string]any + wantErr bool + errMsg string + }{ + { + name: "Validate returns nil succeeds", + conf: map[string]any{"port": 8080}, + wantErr: false, + }, + { + name: "Validate returns error fails", + conf: map[string]any{}, // port omitted => 0, Validate rejects + wantErr: true, + errMsg: "port must be positive", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var target validatingStruct + cfg, err := New(WithSource(source.NewMap(tt.conf)), WithBinding(&target)) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + + if tt.wantErr { + require.Error(t, err) + if tt.errMsg != "" { + assert.ErrorContains(t, err, tt.errMsg) + } + return + } + require.NoError(t, err) + assert.Equal(t, 8080, target.Port) + }) + } +} + +func TestDump(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func() (*Synthra, *recordingDumper, error) + verify func(t *testing.T, dumper *recordingDumper) + wantErr bool + }{ + { + name: "calls dumper successfully", + setup: func() (*Synthra, *recordingDumper, error) { + src := source.NewMap(map[string]any{"foo": "bar"}) + dumper := &recordingDumper{} + cfg, err := New(WithSource(src), WithDumper(dumper)) + if err != nil { + return nil, nil, err + } + if loadErr := cfg.Load(context.Background()); loadErr != nil { + return nil, nil, loadErr + } + return cfg, dumper, nil + }, + verify: func(t *testing.T, dumper *recordingDumper) { + t.Helper() + require.Equal(t, 1, dumper.Calls()) + require.Equal(t, map[string]any{"foo": "bar"}, dumper.Last()) + }, + wantErr: false, + }, + { + name: "succeeds with no dumpers", + setup: func() (*Synthra, *recordingDumper, error) { + src := source.NewMap(map[string]any{"foo": "bar"}) + cfg, err := New(WithSource(src)) + if err != nil { + return nil, nil, err + } + if loadErr := cfg.Load(context.Background()); loadErr != nil { + return nil, nil, loadErr + } + return cfg, nil, nil + }, + verify: nil, + wantErr: false, + }, + { + name: "error propagates from dumper", + setup: func() (*Synthra, *recordingDumper, error) { + src := source.NewMap(map[string]any{"foo": "bar"}) + dumper := &recordingDumper{Err: errors.New("dump error")} + cfg, err := New(WithSource(src), WithDumper(dumper)) + if err != nil { + return nil, nil, err + } + if loadErr := cfg.Load(context.Background()); loadErr != nil { + return nil, nil, loadErr + } + return cfg, dumper, nil + }, + verify: nil, + wantErr: true, + }, + { + name: "calls multiple dumpers", + setup: func() (*Synthra, *recordingDumper, error) { + src := source.NewMap(map[string]any{"foo": "bar"}) + dumper1 := &recordingDumper{} + dumper2 := &recordingDumper{} + cfg, err := New(WithSource(src), WithDumper(dumper1), WithDumper(dumper2)) + if err != nil { + return nil, nil, err + } + if loadErr := cfg.Load(context.Background()); loadErr != nil { + return nil, nil, loadErr + } + // Return first dumper for verification + return cfg, dumper1, nil + }, + verify: func(t *testing.T, dumper *recordingDumper) { + t.Helper() + require.Equal(t, 1, dumper.Calls()) + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg, dumper, err := tt.setup() + require.NoError(t, err, "setup should not fail") + + err = cfg.Dump(context.Background()) + + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + if tt.verify != nil && dumper != nil { + tt.verify(t, dumper) + } + }) + } +} + +func TestDump_NilContext(t *testing.T) { + t.Parallel() + + src := source.NewMap(map[string]any{"foo": "bar"}) + cfg, err := New(WithSource(src)) + require.NoError(t, err) + require.NoError(t, cfg.Load(context.Background())) + + // Testing nil context handling - we need to verify the function properly rejects nil + // Using a helper to call Dump with nil to avoid linter warnings in the main test code + callDumpWithNil := func(c *Synthra) error { + //nolint:staticcheck // SA1012: Intentionally testing nil context error handling + return c.Dump(nil) + } + + err = callDumpWithNil(cfg) + require.Error(t, err) + assert.ErrorIs(t, err, ErrNilContext) +} + +func TestDump_NoLoad(t *testing.T) { + t.Parallel() + + dumper := &recordingDumper{} + cfg, err := New(WithSource(source.NewMap(map[string]any{"foo": "bar"})), WithDumper(dumper)) + require.NoError(t, err) + // Do not call Load + + err = cfg.Dump(context.Background()) + require.NoError(t, err) + assert.Equal(t, 1, dumper.Calls()) + require.NotNil(t, dumper.Last()) + assert.Empty(t, dumper.Last()) +} + +func TestLoad_NilContext(t *testing.T) { + t.Parallel() + + src := source.NewMap(map[string]any{"foo": "bar"}) + cfg, err := New(WithSource(src)) + require.NoError(t, err) + + callLoadWithNil := func(c *Synthra) error { + //nolint:staticcheck // SA1012: Intentionally testing nil context error handling + return c.Load(nil) + } + + err = callLoadWithNil(cfg) + require.Error(t, err) + assert.ErrorIs(t, err, ErrNilContext) +} + +func TestGet(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + conf map[string]any + key string + want any + }{ + { + name: "simple key", + conf: map[string]any{"foo": "bar"}, + key: "foo", + want: "bar", + }, + { + name: "nested key with dot notation", + conf: map[string]any{ + "outer": map[string]any{ + "inner": map[string]any{ + "val": 42, + }, + }, + }, + key: "outer.inner.val", + want: 42, + }, + { + name: "deeply nested key", + conf: map[string]any{ + "a": map[string]any{"b": map[string]any{"c": map[string]any{"d": 1}}}, + }, + key: "a.b.c.d", + want: 1, + }, + { + name: "not found returns nil", + conf: map[string]any{"foo": "bar"}, + key: "notfound", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := loadTestConfig(t, tt.conf) + got := cfg.Get(tt.key) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestGet_EmptyKey(t *testing.T) { + t.Parallel() + + cfg := loadTestConfig(t, map[string]any{"foo": "bar"}) + + got := cfg.Get("") + assert.Nil(t, got) + + _, err := Get[string](cfg, "") + require.Error(t, err) + assert.ErrorContains(t, err, "not found") +} + +func TestGet_MissingKeyReturnsError(t *testing.T) { + t.Parallel() + + cfg := loadTestConfig(t, map[string]any{"foo": "bar"}) + + _, err := Get[int](cfg, "missing") + require.Error(t, err) + assert.ErrorIs(t, err, ErrKeyNotFound) + + _, err = Get[string](cfg, "missing") + require.Error(t, err) + assert.ErrorIs(t, err, ErrKeyNotFound) + + _, err = Get[bool](cfg, "missing") + require.Error(t, err) + assert.ErrorIs(t, err, ErrKeyNotFound) +} + +func TestGet_StringAsIntCoercesViaCast(t *testing.T) { + t.Parallel() + + cfg := loadTestConfig(t, map[string]any{"port": "not-a-number"}) + + // Generic [Get] uses the same coercion path as [GetOr]; invalid numeric + // strings coerce to zero without an error (see [convertToType]). + v, err := Get[int](cfg, "port") + require.NoError(t, err) + assert.Equal(t, 0, v) +} + +func TestGet_NilConfigAndKeyNotFoundAndConversionError(t *testing.T) { + t.Parallel() + + t.Run("nil config returns error", func(t *testing.T) { + t.Parallel() + var cfg *Synthra + _, err := Get[string](cfg, "key") + require.Error(t, err) + assert.ErrorIs(t, err, ErrNilConfig) + }) + + t.Run("key not found returns error", func(t *testing.T) { + t.Parallel() + cfg := loadTestConfig(t, map[string]any{"foo": "bar"}) + _, err := Get[int](cfg, "nonexistent") + require.Error(t, err) + assert.ErrorIs(t, err, ErrKeyNotFound) + }) + + t.Run("value not convertible returns error", func(t *testing.T) { + t.Parallel() + type customType struct{ X int } + cfg := loadTestConfig(t, map[string]any{"key": "string-value"}) + _, err := Get[customType](cfg, "key") + require.Error(t, err) + assert.ErrorContains(t, err, "cannot convert") + assert.ErrorContains(t, err, "key") + }) +} + +func TestGetOr(t *testing.T) { + t.Parallel() + + t.Run("key present returns value", func(t *testing.T) { + t.Parallel() + cfg := loadTestConfig(t, map[string]any{"port": 9090}) + got := GetOr(cfg, "port", 8080) + assert.Equal(t, 9090, got) + }) + + t.Run("key missing returns default", func(t *testing.T) { + t.Parallel() + cfg := loadTestConfig(t, map[string]any{"foo": "bar"}) + got := GetOr(cfg, "port", 8080) + assert.Equal(t, 8080, got) + }) + + t.Run("nil config returns default", func(t *testing.T) { + t.Parallel() + var cfg *Synthra + got := GetOr(cfg, "port", 8080) + assert.Equal(t, 8080, got) + }) +} + +func TestGet_UnsupportedType(t *testing.T) { + t.Parallel() + + type myType struct{} + + cfg := loadTestConfig(t, map[string]any{"custom": "value"}) + + _, err := Get[myType](cfg, "custom") + require.Error(t, err) + assert.ErrorContains(t, err, "cannot convert") +} + +func TestGetTypedValues(t *testing.T) { + t.Parallel() + + timeStr := "2023-01-01T12:00:00Z" + durStr := "1h2m3s" + conf := map[string]any{ + "str": "foo", + "bool": true, + "boolstr": "true", + "int": 42, + "intstr": "42", + "int8": int8(8), + "int16": int16(16), + "int32": int32(32), + "int64": int64(64), + "uint8": uint8(8), + "uint": uint(7), + "uint16": uint16(16), + "uint32": uint32(32), + "uint64": uint64(64), + "float32": float32(1.5), + "float32str": "2.5", + "float64": 3.14, + "floatstr": "2.71", + "time": timeStr, + "duration": durStr, + "intslice": []any{1, 2, 3}, + "strslice": []any{"a", "b"}, + "map": map[string]any{"a": 1}, + "mapstr": map[string]any{"a": "x"}, + "mapstrslice": map[string]any{"a": []any{"x", "y"}}, + } + + cfg := loadTestConfig(t, conf) + + tests := []struct { + name string + testFn func(t *testing.T) + }{ + { + name: "GetString", + testFn: func(t *testing.T) { + t.Helper() + assert.Equal(t, "foo", mustString(t, cfg, "str")) + v, err := Get[string](cfg, "str") + require.NoError(t, err) + assert.Equal(t, "foo", v) + _, err = Get[string](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetBool", + testFn: func(t *testing.T) { + t.Helper() + assert.True(t, mustBool(t, cfg, "bool")) + b, err := Get[bool](cfg, "boolstr") + require.NoError(t, err) + assert.True(t, b) + _, err = Get[bool](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetInt", + testFn: func(t *testing.T) { + t.Helper() + assert.Equal(t, 42, mustInt(t, cfg, "int")) + i, err := Get[int](cfg, "intstr") + require.NoError(t, err) + assert.Equal(t, 42, i) + _, err = Get[int](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetInt8", + testFn: func(t *testing.T) { + t.Helper() + i8, err := Get[int8](cfg, "int8") + require.NoError(t, err) + assert.Equal(t, int8(8), i8) + _, err = Get[int8](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetInt16", + testFn: func(t *testing.T) { + t.Helper() + i16, err := Get[int16](cfg, "int16") + require.NoError(t, err) + assert.Equal(t, int16(16), i16) + _, err = Get[int16](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetInt32", + testFn: func(t *testing.T) { + t.Helper() + i32, err := Get[int32](cfg, "int32") + require.NoError(t, err) + assert.Equal(t, int32(32), i32) + _, err = Get[int32](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetInt64", + testFn: func(t *testing.T) { + t.Helper() + assert.Equal(t, int64(64), mustInt64(t, cfg, "int64")) + i64, err := Get[int64](cfg, "int64") + require.NoError(t, err) + assert.Equal(t, int64(64), i64) + _, err = Get[int64](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetUint8", + testFn: func(t *testing.T) { + t.Helper() + u8, err := Get[uint8](cfg, "uint8") + require.NoError(t, err) + assert.Equal(t, uint8(8), u8) + _, err = Get[uint8](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetUint", + testFn: func(t *testing.T) { + t.Helper() + u, err := Get[uint](cfg, "uint") + require.NoError(t, err) + assert.Equal(t, uint(7), u) + _, err = Get[uint](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetUint16", + testFn: func(t *testing.T) { + t.Helper() + u16, err := Get[uint16](cfg, "uint16") + require.NoError(t, err) + assert.Equal(t, uint16(16), u16) + _, err = Get[uint16](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetUint32", + testFn: func(t *testing.T) { + t.Helper() + u32, err := Get[uint32](cfg, "uint32") + require.NoError(t, err) + assert.Equal(t, uint32(32), u32) + _, err = Get[uint32](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetUint64", + testFn: func(t *testing.T) { + t.Helper() + u64, err := Get[uint64](cfg, "uint64") + require.NoError(t, err) + assert.Equal(t, uint64(64), u64) + _, err = Get[uint64](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetFloat32", + testFn: func(t *testing.T) { + t.Helper() + f32, err := Get[float32](cfg, "float32str") + require.NoError(t, err) + assert.InDelta(t, 2.5, float64(f32), 0.0001) + _, err = Get[float32](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetFloat64", + testFn: func(t *testing.T) { + t.Helper() + assert.InDelta(t, 3.14, mustFloat64(t, cfg, "float64"), 0.0001) + f64, err := Get[float64](cfg, "floatstr") + require.NoError(t, err) + assert.InDelta(t, 2.71, f64, 0.0001) + _, err = Get[float64](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetTime", + testFn: func(t *testing.T) { + t.Helper() + tm, err := Get[time.Time](cfg, "time") + require.NoError(t, err) + assert.Equal(t, time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), tm) + assert.Equal(t, time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), mustTime(t, cfg, "time")) + _, err = Get[time.Time](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetDuration", + testFn: func(t *testing.T) { + t.Helper() + d, err := Get[time.Duration](cfg, "duration") + require.NoError(t, err) + assert.Equal(t, 1*time.Hour+2*time.Minute+3*time.Second, d) + assert.Equal(t, 1*time.Hour+2*time.Minute+3*time.Second, mustDuration(t, cfg, "duration")) + _, err = Get[time.Duration](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetIntSlice", + testFn: func(t *testing.T) { + t.Helper() + assert.Equal(t, []int{1, 2, 3}, mustIntSlice(t, cfg, "intslice")) + is, err := Get[[]int](cfg, "intslice") + require.NoError(t, err) + assert.Equal(t, []int{1, 2, 3}, is) + _, err = Get[[]int](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetStringSlice", + testFn: func(t *testing.T) { + t.Helper() + assert.Equal(t, []string{"a", "b"}, mustStringSlice(t, cfg, "strslice")) + ss, err := Get[[]string](cfg, "strslice") + require.NoError(t, err) + assert.Equal(t, []string{"a", "b"}, ss) + _, err = Get[[]string](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetStringMap", + testFn: func(t *testing.T) { + t.Helper() + assert.Equal(t, map[string]any{"a": 1}, mustStringMap(t, cfg, "map")) + m, err := Get[map[string]any](cfg, "map") + require.NoError(t, err) + assert.Equal(t, map[string]any{"a": 1}, m) + _, err = Get[map[string]any](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetStringMapString", + testFn: func(t *testing.T) { + t.Helper() + ms, err := Get[map[string]string](cfg, "mapstr") + require.NoError(t, err) + assert.Equal(t, map[string]string{"a": "x"}, ms) + _, err = Get[map[string]string](cfg, "notfound") + assert.Error(t, err) + }, + }, + { + name: "GetStringMapStringSlice", + testFn: func(t *testing.T) { + t.Helper() + mss, err := Get[map[string][]string](cfg, "mapstrslice") + require.NoError(t, err) + assert.Equal(t, map[string][]string{"a": {"x", "y"}}, mss) + _, err = Get[map[string][]string](cfg, "notfound") + assert.Error(t, err) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tt.testFn(t) + }) + } +} + +func TestValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + conf map[string]any + validator func(map[string]any) error + wantErr bool + errMsg string + }{ + { + name: "validator passes", + conf: map[string]any{"foo": "baz"}, + validator: func(cfg map[string]any) error { + if cfg["foo"] != "baz" { + return errors.New("foo must be 'baz'") + } + return nil + }, + wantErr: false, + }, + { + name: "validator fails", + conf: map[string]any{"foo": "bar"}, + validator: func(cfg map[string]any) error { + if cfg["foo"] != "baz" { + return errors.New("foo must be 'baz'") + } + return nil + }, + wantErr: true, + }, + { + name: "validator panic is caught", + conf: map[string]any{"foo": "bar"}, + validator: func(_ map[string]any) error { + panic("validator panic") + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + src := source.NewMap(tt.conf) + cfg, err := New(WithSource(src), WithValidator(tt.validator)) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + + if tt.wantErr { + require.Error(t, err) + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg) + } + return + } + + require.NoError(t, err) + }) + } +} + +func TestJSONSchemaValidation(t *testing.T) { + t.Parallel() + + schema := []byte(`{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"foo":{"type":"string"},"bar":{"type":"integer"}},"required":["foo","bar"]}`) + + tests := []struct { + name string + conf map[string]any + wantErr bool + }{ + { + name: "valid data passes", + conf: map[string]any{"foo": "bar", "bar": 42}, + wantErr: false, + }, + { + name: "invalid data fails", + conf: map[string]any{"foo": "bar", "bar": "notanint"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + src := source.NewMap(tt.conf) + cfg, err := New(WithSource(src), WithJSONSchema(schema)) + require.NoError(t, err) + + err = cfg.Load(context.Background()) + + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + }) + } +} + +func TestNew_WithJSONSchema_ErrorCase(t *testing.T) { + t.Parallel() + + t.Run("invalid JSON schema fails", func(t *testing.T) { + t.Parallel() + + _, err := New(WithSource(source.NewMap(map[string]any{"foo": "bar"})), WithJSONSchema([]byte(`{invalid json`))) + require.Error(t, err) + }) + + t.Run("schema that fails to compile returns error", func(t *testing.T) { + t.Parallel() + // Schema with invalid $ref that does not exist - Compile fails + schema := []byte(`{"$ref": "#/definitions/Missing"}`) + _, err := New(WithSource(source.NewMap(map[string]any{"foo": "bar"})), WithJSONSchema(schema)) + require.Error(t, err) + }) +} + +func TestWithFileAs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + decoder codec.Decoder + wantErr bool + }{ + { + name: "valid path and codec", + path: "/tmp/config.json", + decoder: codec.JSON, + wantErr: false, + }, + { + name: "valid path with YAML codec", + path: "/tmp/config.yaml", + decoder: codec.YAML, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg, err := New(WithFileAs(tt.path, tt.decoder)) + + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.NotNil(t, cfg) + assert.Len(t, cfg.sources, 1) + }) + } +} + +func TestWithContent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data []byte + decoder codec.Decoder + wantErr bool + }{ + { + name: "valid JSON content", + data: []byte(`{"foo": "bar"}`), + decoder: codec.JSON, + wantErr: false, + }, + { + name: "valid YAML content", + data: []byte("foo: bar"), + decoder: codec.YAML, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg, err := New(WithContent(tt.data, tt.decoder)) + + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.NotNil(t, cfg) + assert.Len(t, cfg.sources, 1) + }) + } +} + +func TestWithEnv(t *testing.T) { + t.Parallel() + + cfg, err := New(WithEnv("TESTPREFIX_")) + require.NoError(t, err) + assert.NotNil(t, cfg) + assert.Len(t, cfg.sources, 1) +} + +func TestWithConsul_RequiresEnvVar(t *testing.T) { + // Cannot use t.Parallel() with t.Setenv (testing package restriction). + t.Setenv("CONSUL_HTTP_ADDR", "") + + cfg, err := New(WithConsul("production/service.yaml")) + require.Error(t, err) + assert.Nil(t, cfg) + assert.Contains(t, err.Error(), "CONSUL_HTTP_ADDR") +} + +func TestWithConsulAs_RequiresEnvVar(t *testing.T) { + t.Setenv("CONSUL_HTTP_ADDR", "") + + cfg, err := New(WithConsulAs("production/service", codec.JSON)) + require.Error(t, err) + assert.Nil(t, cfg) + assert.Contains(t, err.Error(), "CONSUL_HTTP_ADDR") +} + +func TestWithIf_WithConsul_SkipsWithoutEnvVar(t *testing.T) { + t.Setenv("CONSUL_HTTP_ADDR", "") + + cfg, err := New(WithIf(os.Getenv("CONSUL_HTTP_ADDR") != "", WithConsul("production/service.yaml"))) + require.NoError(t, err) + assert.NotNil(t, cfg) + assert.Len(t, cfg.sources, 0) +} + +func TestWithIf_WithConsulAs_SkipsWithoutEnvVar(t *testing.T) { + t.Setenv("CONSUL_HTTP_ADDR", "") + + cfg, err := New(WithIf(os.Getenv("CONSUL_HTTP_ADDR") != "", WithConsulAs("production/service", codec.JSON))) + require.NoError(t, err) + assert.NotNil(t, cfg) + assert.Len(t, cfg.sources, 0) +} + +func TestWithIf_AppliesWhenConditionTrue(t *testing.T) { + cfg, err := New(WithIf(true, WithSource(source.NewMap(map[string]any{"service": map[string]any{"name": "edge"}})))) + require.NoError(t, err) + assert.NotNil(t, cfg) + assert.Len(t, cfg.sources, 1) +} + +func TestWithFile_ExpandsEnvVars(t *testing.T) { + t.Parallel() + + // Set up test environment variable with unique name + tmpDir := t.TempDir() + envVar := "TEST_CONFIG_DIR_WITHFILE" + require.NoError(t, os.Setenv(envVar, tmpDir)) + defer func() { + require.NoError(t, os.Unsetenv(envVar)) + }() + + // Create test file + testFile := filepath.Join(tmpDir, "test_env_expand.yaml") + testData := []byte("test: value") + require.NoError(t, os.WriteFile(testFile, testData, 0o600)) + + // Test with environment variable expansion + cfg, err := New(WithFile("${" + envVar + "}/test_env_expand.yaml")) + require.NoError(t, err) + assert.NotNil(t, cfg) + assert.Len(t, cfg.sources, 1) + + // Verify it actually loads + require.NoError(t, cfg.Load(context.Background())) + assert.Equal(t, "value", mustString(t, cfg, "test")) +} + +func TestWithFileAs_ExpandsEnvVars(t *testing.T) { + t.Parallel() + + // Set up test environment variable with unique name + tmpDir := t.TempDir() + envVar := "TEST_CONFIG_DIR_WITHFILEAS" + require.NoError(t, os.Setenv(envVar, tmpDir)) + defer func() { + require.NoError(t, os.Unsetenv(envVar)) + }() + + // Create test file without extension + testFile := filepath.Join(tmpDir, "test_env_expand_noext") + testData := []byte("test: value") + require.NoError(t, os.WriteFile(testFile, testData, 0o600)) + + // Test with environment variable expansion + cfg, err := New(WithFileAs("${"+envVar+"}/test_env_expand_noext", codec.YAML)) + require.NoError(t, err) + assert.NotNil(t, cfg) + assert.Len(t, cfg.sources, 1) + + // Verify it actually loads + require.NoError(t, cfg.Load(context.Background())) + assert.Equal(t, "value", mustString(t, cfg, "test")) +} + +func TestWithFileDumper_ExpandsEnvVars(t *testing.T) { + t.Parallel() + + // Set up test environment variable with unique name + tmpDir := t.TempDir() + envVar := "TEST_OUTPUT_DIR_WITHFILEDUMPER" + require.NoError(t, os.Setenv(envVar, tmpDir)) + defer func() { + require.NoError(t, os.Unsetenv(envVar)) + }() + + outputFile := filepath.Join(tmpDir, "test_env_expand_dump.yaml") + + // Test with environment variable expansion + cfg, err := New( + WithContent([]byte("test: value"), codec.YAML), + WithFileDumper("${"+envVar+"}/test_env_expand_dump.yaml"), + ) + require.NoError(t, err) + assert.NotNil(t, cfg) + assert.Len(t, cfg.dumpers, 1) + + // Load and dump + require.NoError(t, cfg.Load(context.Background())) + require.NoError(t, cfg.Dump(context.Background())) + + // Verify file was created + _, err = os.Stat(outputFile) + assert.NoError(t, err) +} + +func TestWithFileDumperAs_ExpandsEnvVars(t *testing.T) { + t.Parallel() + + // Set up test environment variable with unique name + tmpDir := t.TempDir() + envVar := "TEST_OUTPUT_DIR_WITHFILEDUMPERAS" + require.NoError(t, os.Setenv(envVar, tmpDir)) + defer func() { + require.NoError(t, os.Unsetenv(envVar)) + }() + + outputFile := filepath.Join(tmpDir, "test_env_expand_dump_noext") + + // Test with environment variable expansion + cfg, err := New( + WithContent([]byte("test: value"), codec.YAML), + WithFileDumperAs("${"+envVar+"}/test_env_expand_dump_noext", codec.YAML), + ) + require.NoError(t, err) + assert.NotNil(t, cfg) + assert.Len(t, cfg.dumpers, 1) + + // Load and dump + require.NoError(t, cfg.Load(context.Background())) + require.NoError(t, cfg.Dump(context.Background())) + + // Verify file was created + _, err = os.Stat(outputFile) + assert.NoError(t, err) +} + +func TestWithConsul_ExpandsEnvVars(t *testing.T) { + t.Parallel() + + // Set up test environment variables + require.NoError(t, os.Setenv("TEST_APP_ENV", "staging")) + require.NoError(t, os.Setenv("CONSUL_HTTP_ADDR", "http://localhost:8500")) + defer func() { + require.NoError(t, os.Unsetenv("TEST_APP_ENV")) + require.NoError(t, os.Unsetenv("CONSUL_HTTP_ADDR")) + }() + + // Test with environment variable expansion + // Note: This will try to connect to Consul, so we expect an error + // but we're just verifying the path expansion happens + cfg, err := New(WithConsul("${TEST_APP_ENV}/service.yaml")) + + // We expect the config to be created (env var expanded) + // but Load() will fail since there's no actual Consul + assert.NotNil(t, cfg) + // The error check is relaxed because Consul connection may fail + // The important thing is the path was expanded before being used + _ = err +} + +func TestWithConsulAs_ExpandsEnvVars(t *testing.T) { + t.Parallel() + + // Set up test environment variables + require.NoError(t, os.Setenv("TEST_APP_ENV", "staging")) + require.NoError(t, os.Setenv("CONSUL_HTTP_ADDR", "http://localhost:8500")) + defer func() { + require.NoError(t, os.Unsetenv("TEST_APP_ENV")) + require.NoError(t, os.Unsetenv("CONSUL_HTTP_ADDR")) + }() + + // Test with environment variable expansion + cfg, err := New(WithConsulAs("${TEST_APP_ENV}/service", codec.JSON)) + + // We expect the config to be created (env var expanded) + assert.NotNil(t, cfg) + // The error check is relaxed because Consul connection may fail + _ = err +} + +func TestWithFileDumper(t *testing.T) { + t.Parallel() + + path := "/tmp/config_test_file_dumper.json" + cfg, err := New(WithFileDumperAs(path, codec.JSON)) + require.NoError(t, err) + assert.NotNil(t, cfg) + assert.Len(t, cfg.dumpers, 1) +} + +func TestConfigError(t *testing.T) { + t.Parallel() + + baseErr := errors.New("base error") + + tests := []struct { + name string + err *ConfigError + wantMsg string + wantUnwrap error + }{ + { + name: "load with path", + err: &ConfigError{ + Op: OpLoad, + Path: "source[1]", + Err: baseErr, + }, + wantMsg: "synthra: load source[1]: base error", + wantUnwrap: baseErr, + }, + { + name: "new without path", + err: &ConfigError{ + Op: OpNew, + Err: baseErr, + }, + wantMsg: "synthra: new: base error", + wantUnwrap: baseErr, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.wantMsg, tt.err.Error()) + assert.Equal(t, tt.wantUnwrap, tt.err.Unwrap()) + }) + } +} + +func TestConcurrency(t *testing.T) { + t.Parallel() + + t.Run("concurrent Load", func(t *testing.T) { + t.Parallel() + + src := source.NewMap(map[string]any{"foo": "bar"}) + cfg, err := New(WithSource(src)) + require.NoError(t, err) + + wg := make(chan struct{}) + for range 10 { + go func() { + loadErr := cfg.Load(context.Background()) + if loadErr != nil { + t.Error(loadErr) + } + wg <- struct{}{} + }() + } + for range 10 { + <-wg + } + }) + + t.Run("concurrent Get", func(t *testing.T) { + t.Parallel() + + src := source.NewMap(map[string]any{"foo": "bar"}) + cfg, err := New(WithSource(src)) + require.NoError(t, err) + require.NoError(t, cfg.Load(context.Background())) + + wg := make(chan struct{}) + for range 10 { + go func() { + _ = cfg.Get("foo") + loadErr := cfg.Load(context.Background()) + if loadErr != nil { + t.Error(loadErr) + } + wg <- struct{}{} + }() + } + for range 10 { + <-wg + } + }) + + t.Run("concurrent Get and Load with binding validation", func(t *testing.T) { + t.Parallel() + + type validatingBindStruct struct { + Foo string `synthra:"foo"` + Bar int `synthra:"bar"` + } + + src := source.NewMap(map[string]any{"foo": "bar", "bar": 42}) + var bind validatingBindStruct + cfg, err := New(WithSource(src), WithBinding(&bind)) + require.NoError(t, err) + require.NoError(t, cfg.Load(context.Background())) + + wg := make(chan struct{}) + for range 20 { + go func() { + defer func() { wg <- struct{}{} }() + for i := range 10 { + if i%2 == 0 { + _ = cfg.Get("foo") + _, _ = cfg.String("foo") //nolint:errcheck // concurrency stress + _, _ = cfg.Int("bar") //nolint:errcheck // concurrency stress + _ = cfg.Values() + } else { + loadErr := cfg.Load(context.Background()) + if loadErr != nil { + t.Error(loadErr) + } + } + } + }() + } + + for range 20 { + <-wg + } + + assert.Equal(t, "bar", mustString(t, cfg, "foo")) + assert.Equal(t, 42, mustInt(t, cfg, "bar")) + }) + + t.Run("concurrent access to same key", func(t *testing.T) { + t.Parallel() + + src := source.NewMap(map[string]any{"shared": "value"}) + cfg, err := New(WithSource(src)) + require.NoError(t, err) + require.NoError(t, cfg.Load(context.Background())) + + var wg sync.WaitGroup + //nolint:makezero // indexed assignment requires pre-allocated length + results := make([]string, 10) + + for i := range 10 { + wg.Add(1) + go func(index int) { + defer wg.Done() + s, serr := cfg.String("shared") + if serr != nil { + t.Error(serr) + return + } + results[index] = s + }(i) + } + + wg.Wait() + + for _, result := range results { + assert.Equal(t, "value", result) + } + }) +} + +func TestReload(t *testing.T) { + t.Parallel() + + data := map[string]any{"foo": "bar"} + src := source.NewMap(data) + cfg, err := New(WithSource(src)) + require.NoError(t, err) + require.NoError(t, cfg.Load(context.Background())) + assert.Equal(t, "bar", mustString(t, cfg, "foo")) + + data["foo"] = "baz" + require.NoError(t, cfg.Load(context.Background())) + assert.Equal(t, "baz", mustString(t, cfg, "foo")) +} + +func TestNilConfigInstance(t *testing.T) { + t.Parallel() + + var cfg *Synthra + + for _, tc := range []struct { + name string + run func() error + }{ + {"String", func() error { _, err := cfg.String("any"); return err }}, + {"Bool", func() error { _, err := cfg.Bool("any"); return err }}, + {"Int", func() error { _, err := cfg.Int("any"); return err }}, + {"Int64", func() error { _, err := cfg.Int64("any"); return err }}, + {"Float64", func() error { _, err := cfg.Float64("any"); return err }}, + {"Duration", func() error { _, err := cfg.Duration("any"); return err }}, + {"Time", func() error { _, err := cfg.Time("any"); return err }}, + {"StringSlice", func() error { _, err := cfg.StringSlice("any"); return err }}, + {"IntSlice", func() error { _, err := cfg.IntSlice("any"); return err }}, + {"StringMap", func() error { _, err := cfg.StringMap("any"); return err }}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := tc.run() + require.Error(t, err) + assert.ErrorIs(t, err, ErrNilConfig) + }) + } + + assert.Nil(t, cfg.Get("any")) + + _, err := Get[string](cfg, "any") + require.Error(t, err) + assert.ErrorIs(t, err, ErrNilConfig) +} + +func TestConfigOrMethods_NilConfigAndMissingKey(t *testing.T) { + t.Parallel() + + t.Run("nil config returns default for Or methods", func(t *testing.T) { + t.Parallel() + var cfg *Synthra + assert.Equal(t, "default", cfg.StringOr("key", "default")) + assert.Equal(t, 8080, cfg.IntOr("key", 8080)) + assert.Equal(t, int64(1024), cfg.Int64Or("key", 1024)) + assert.Equal(t, 0.5, cfg.Float64Or("key", 0.5)) + assert.True(t, cfg.BoolOr("key", true)) + assert.Equal(t, 30*time.Second, cfg.DurationOr("key", 30*time.Second)) + assert.Equal(t, []string{"a"}, cfg.StringSliceOr("key", []string{"a"})) + assert.Equal(t, []int{1}, cfg.IntSliceOr("key", []int{1})) + assert.Equal(t, map[string]any{"x": "y"}, cfg.StringMapOr("key", map[string]any{"x": "y"})) + }) + + t.Run("missing key returns default for Or methods", func(t *testing.T) { + t.Parallel() + cfg := loadTestConfig(t, map[string]any{"foo": "bar"}) + assert.Equal(t, "default", cfg.StringOr("missing", "default")) + assert.Equal(t, 8080, cfg.IntOr("missing", 8080)) + assert.Equal(t, int64(1024), cfg.Int64Or("missing", 1024)) + assert.Equal(t, 0.5, cfg.Float64Or("missing", 0.5)) + assert.True(t, cfg.BoolOr("missing", true)) + assert.Equal(t, 30*time.Second, cfg.DurationOr("missing", 30*time.Second)) + assert.Equal(t, []string{"a"}, cfg.StringSliceOr("missing", []string{"a"})) + assert.Equal(t, []int{1}, cfg.IntSliceOr("missing", []int{1})) + assert.Equal(t, map[string]any{"x": "y"}, cfg.StringMapOr("missing", map[string]any{"x": "y"})) + }) +} + +func TestLargeConfiguration(t *testing.T) { + t.Parallel() + + largeConfig := make(map[string]any, 1000) + for i := range 1000 { + largeConfig[fmt.Sprintf("key%d", i)] = fmt.Sprintf("value%d", i) + } + + cfg := loadTestConfig(t, largeConfig) + + assert.Equal(t, "value0", mustString(t, cfg, "key0")) + assert.Equal(t, "value999", mustString(t, cfg, "key999")) + assert.Equal(t, "value500", mustString(t, cfg, "key500")) +} + +func TestContextCancellation(t *testing.T) { + t.Parallel() + + type mockContextAwareSource struct { + conf map[string]any + err error + } + + mockCtxSource := &mockContextAwareSource{conf: map[string]any{"foo": "bar"}} + + // Implement Source interface + loadFunc := func(ctx context.Context) (map[string]any, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(50 * time.Millisecond): + return mockCtxSource.conf, mockCtxSource.err //nolint:nilnil // Test mock intentionally returns (nil, nil) for certain test cases + } + } + + // Use loadFunc to avoid unused variable warning + _ = loadFunc + + cfg, err := New(WithSource(source.NewMap(map[string]any{"foo": "bar"}))) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + // This test is primarily to show context handling + err = cfg.Load(ctx) + if err != nil { + t.Fatal(err) + } +} + +func TestFilePermissions(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + sourceFile := tmpDir + "/source.yaml" + dumpFile := tmpDir + "/dump.yaml" + + sourceContent := []byte("foo: bar\n") + err := os.WriteFile(sourceFile, sourceContent, 0o600) + require.NoError(t, err) + + cfg, err := New( + WithFileAs(sourceFile, codec.YAML), + WithFileDumperAs(dumpFile, codec.YAML), + ) + require.NoError(t, err) + require.NoError(t, cfg.Load(context.Background())) + require.NoError(t, cfg.Dump(context.Background())) + + info, err := os.Stat(dumpFile) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o644), info.Mode().Perm()) +} + +func TestConsistentReturnTypes(t *testing.T) { + t.Parallel() + + cfg := loadTestConfig(t, map[string]any{"existing": "value"}) + + t.Run("slices return empty not nil", func(t *testing.T) { + intSlice, err := Get[[]int](cfg, "nonexistent") + require.Error(t, err) + assert.NotNil(t, intSlice) + assert.Len(t, intSlice, 0) + + stringSlice, err := Get[[]string](cfg, "nonexistent") + require.Error(t, err) + assert.NotNil(t, stringSlice) + assert.Len(t, stringSlice, 0) + }) + + t.Run("maps return empty not nil", func(t *testing.T) { + stringMap, err := Get[map[string]any](cfg, "nonexistent") + require.Error(t, err) + assert.NotNil(t, stringMap) + assert.Len(t, stringMap, 0) + + stringMapString, err := Get[map[string]string](cfg, "nonexistent") + require.Error(t, err) + assert.NotNil(t, stringMapString) + assert.Len(t, stringMapString, 0) + + stringMapStringSlice, err := Get[map[string][]string](cfg, "nonexistent") + require.Error(t, err) + assert.NotNil(t, stringMapStringSlice) + assert.Len(t, stringMapStringSlice, 0) + }) +} + +func TestCaseInsensitiveMerging(t *testing.T) { + t.Parallel() + + // Test data with mixed case keys + config1 := []byte(`{ + "Server": { + "Host": "localhost", + "Port": 8080 + }, + "Database": { + "Name": "testdb" + } + }`) + + config2 := []byte(`{ + "server": { + "host": "example.com", + "port": 9090 + }, + "database": { + "name": "prod" + } + }`) + + // Create configuration with both sources + cfg, err := New( + WithContent(config1, codec.JSON), + WithContent(config2, codec.JSON), + ) + require.NoError(t, err) + + // Load configuration + err = cfg.Load(context.Background()) + require.NoError(t, err) + + tests := []struct { + name string + key string + wantStr string + wantInt int + getType string // "string" or "int" + }{ + { + name: "server.host lowercase", + key: "server.host", + wantStr: "example.com", + getType: "string", + }, + { + name: "Server.Host mixed case", + key: "Server.Host", + wantStr: "example.com", + getType: "string", + }, + { + name: "SERVER.HOST uppercase", + key: "SERVER.HOST", + wantStr: "example.com", + getType: "string", + }, + { + name: "server.port lowercase", + key: "server.port", + wantInt: 9090, + getType: "int", + }, + { + name: "Server.Port mixed case", + key: "Server.Port", + wantInt: 9090, + getType: "int", + }, + { + name: "SERVER.PORT uppercase", + key: "SERVER.PORT", + wantInt: 9090, + getType: "int", + }, + { + name: "database.name lowercase", + key: "database.name", + wantStr: "prod", + getType: "string", + }, + { + name: "Database.Name mixed case", + key: "Database.Name", + wantStr: "prod", + getType: "string", + }, + { + name: "DATABASE.NAME uppercase", + key: "DATABASE.NAME", + wantStr: "prod", + getType: "string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + switch tt.getType { + case "string": + got, strErr := cfg.String(tt.key) + require.NoError(t, strErr) + assert.Equal(t, tt.wantStr, got) + case "int": + got, intErr := cfg.Int(tt.key) + require.NoError(t, intErr) + assert.Equal(t, tt.wantInt, got) + } + }) + } +} + +func TestNormalizeMapKeys(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input map[string]any + expected map[string]any + }{ + { + name: "normalizes all keys to lowercase", + input: map[string]any{ + "Server": map[string]any{ + "Host": "localhost", + "Port": 8080, + }, + "Database": map[string]any{ + "Name": "testdb", + "Settings": map[string]any{ + "MaxConnections": 100, + }, + }, + }, + expected: map[string]any{ + "server": map[string]any{ + "host": "localhost", + "port": 8080, + }, + "database": map[string]any{ + "name": "testdb", + "settings": map[string]any{ + "maxconnections": 100, + }, + }, + }, + }, + { + name: "handles already lowercase keys", + input: map[string]any{ + "server": "localhost", + "port": 8080, + }, + expected: map[string]any{ + "server": "localhost", + "port": 8080, + }, + }, + { + name: "handles uppercase keys", + input: map[string]any{ + "SERVER": "localhost", + "PORT": 8080, + }, + expected: map[string]any{ + "server": "localhost", + "port": 8080, + }, + }, + { + name: "handles mixed case keys", + input: map[string]any{ + "MyServer": "localhost", + "MyPort": 8080, + }, + expected: map[string]any{ + "myserver": "localhost", + "myport": 8080, + }, + }, + { + name: "handles empty map", + input: map[string]any{}, + expected: map[string]any{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + normalized := normalizeMapKeys(tt.input) + assert.Equal(t, tt.expected, normalized) + }) + } +} diff --git a/synthratest/doc.go b/synthratest/doc.go new file mode 100644 index 0000000..eabb84b --- /dev/null +++ b/synthratest/doc.go @@ -0,0 +1,78 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package synthratest provides test helpers for packages that import +// [gopherly.dev/synthra]. +// +// It depends on [github.com/stretchr/testify/require] for concise failures. +// Helpers that take [testing.T] call [testing.T.Helper] so failures report +// the caller’s line. +// +// # Map-backed configuration +// +// Use [Load] to prepend a [gopherly.dev/synthra/source.Map] source and load +// with [testing.T.Context]: +// +// func TestApp(t *testing.T) { +// t.Parallel() +// cfg := synthratest.Load(t, map[string]any{ +// "app": "demo", +// "port": 8080, +// }) +// synthratest.AssertString(t, cfg, "app", "demo") +// synthratest.AssertInt(t, cfg, "port", 8080) +// } +// +// # File-backed configuration +// +// Use [WriteFile] and [LoadFile] to exercise real decoding and paths: +// +// func TestFromYAML(t *testing.T) { +// t.Parallel() +// yaml := []byte("service:\n name: api\n") +// cfg := synthratest.LoadFile(t, synthratest.YAML, yaml) +// synthratest.AssertString(t, cfg, "service.name", "api") +// } +// +// [Format] values are [YAML], [JSON], and [TOML] for the temp file extension. +// +// # Deferred load +// +// Use [Config] when you need a [*synthra.Synthra] but want a custom +// [context.Context] or load sequence: +// +// func TestCustomContext(t *testing.T) { +// t.Parallel() +// cfg := synthratest.Config(t, synthra.WithSource(source.NewMap(map[string]any{ +// "k": "v", +// }))) +// require.NoError(t, cfg.Load(context.Background())) +// synthratest.AssertString(t, cfg, "k", "v") +// } +// +// # Assertions +// +// [AssertString], [AssertInt], [AssertBool], and [AssertStringSlice] wrap +// [github.com/stretchr/testify/require] with typed [synthra.Synthra] getters. +// +// # Test doubles +// +// [ErrSource] returns a [synthra.Source] that always fails with a fixed error. +// [Dumper] records [synthra.Synthra.Dump] calls; use [AssertDumped] to assert +// one call and the last map. [FuncCodec] is a minimal +// [gopherly.dev/synthra/codec.Decoder] and [gopherly.dev/synthra/codec.Encoder] +// backed by functions for table-driven codec tests. +// +// Runnable examples for the doubles and [FuncCodec] live in example_test.go. +package synthratest diff --git a/synthratest/example_test.go b/synthratest/example_test.go new file mode 100644 index 0000000..60f91e6 --- /dev/null +++ b/synthratest/example_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !integration + +package synthratest_test + +import ( + "context" + "errors" + "fmt" + + "gopherly.dev/synthra" + "gopherly.dev/synthra/synthratest" +) + +func ExampleDumper() { + d := &synthratest.Dumper{} + ctx := context.Background() + m := map[string]any{"region": "us-east"} + if err := d.Dump(ctx, &m); err != nil { + fmt.Println("dump error:", err) + return + } + fmt.Println("calls", d.Calls(), "region", d.Last()["region"]) + + // Output: calls 1 region us-east +} + +func ExampleErrSource() { + src := synthratest.ErrSource(errors.New("upstream unavailable")) + cfg, err := synthra.New(synthra.WithSource(src)) + if err != nil { + fmt.Println("new:", err) + return + } + err = cfg.Load(context.Background()) + fmt.Println(err != nil) + + // Output: true +} + +func ExampleFuncCodec() { + c := &synthratest.FuncCodec{ + DecodeFunc: func(data []byte, v any) error { + p, ok := v.(*string) + if !ok { + return errors.New("want *string") + } + *p = string(data) + return nil + }, + EncodeFunc: func(v any) ([]byte, error) { + s, ok := v.(string) + if !ok { + return nil, errors.New("want string") + } + return []byte(s), nil + }, + } + var out string + if err := c.Decode([]byte("decoded"), &out); err != nil { + fmt.Println("decode:", err) + return + } + enc, err := c.Encode("encoded") + if err != nil { + fmt.Println("encode:", err) + return + } + fmt.Println(out, string(enc)) + + // Output: decoded encoded +} + +func ExampleFormat() { + fmt.Println(synthratest.YAML, synthratest.JSON, synthratest.TOML) + + // Output: yaml json toml +} diff --git a/synthratest/synthratest.go b/synthratest/synthratest.go new file mode 100644 index 0000000..d3e0be0 --- /dev/null +++ b/synthratest/synthratest.go @@ -0,0 +1,192 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package synthratest + +import ( + "context" + "maps" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/require" + "gopherly.dev/synthra" + "gopherly.dev/synthra/source" +) + +// Format identifies a config file extension for [WriteFile] and [LoadFile]. +type Format string + +const ( + YAML Format = "yaml" + JSON Format = "json" + TOML Format = "toml" +) + +// Config constructs a [*synthra.Synthra] without calling Load. +// It fails the test if [synthra.New] returns an error. +func Config(t *testing.T, opts ...synthra.Option) *synthra.Synthra { + t.Helper() + cfg, err := synthra.New(opts...) + require.NoError(t, err) + return cfg +} + +// Load constructs and loads a [*synthra.Synthra] using m as its primary source. +// It prepends [synthra.WithSource] with [source.NewMap](m) before opts. +// It uses [testing.T.Context]. For another context, use [Config] then +// [*synthra.Synthra.Load]. +func Load(t *testing.T, m map[string]any, opts ...synthra.Option) *synthra.Synthra { + t.Helper() + all := append([]synthra.Option{synthra.WithSource(source.NewMap(m))}, opts...) + cfg := Config(t, all...) + require.NoError(t, cfg.Load(t.Context())) + return cfg +} + +// WriteFile writes content to config. under [testing.T.TempDir] +// and returns the path. +// Cleanup is handled by [testing.T.TempDir]. +func WriteFile(t *testing.T, format Format, content []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config."+string(format)) + require.NoError(t, os.WriteFile(path, content, 0o600)) + return path +} + +// LoadFile builds and loads a Synthra from a temp file from [WriteFile]. +func LoadFile(t *testing.T, format Format, content []byte) *synthra.Synthra { + t.Helper() + cfg := Config(t, synthra.WithFile(WriteFile(t, format, content))) + require.NoError(t, cfg.Load(t.Context())) + return cfg +} + +// ErrSource returns a [synthra.Source] whose Load always fails with err. +// It panics if err is nil, because that would make Load return (nil, nil). +func ErrSource(err error) synthra.Source { + if err == nil { + panic("synthratest: ErrSource requires non-nil error") + } + return errSource{err: err} +} + +type errSource struct{ err error } + +func (s errSource) Load(context.Context) (map[string]any, error) { + return nil, s.err +} + +// Dumper is a recording [synthra.Dumper]. It is safe for concurrent use. +// The zero value is usable. Set [Dumper.Err] so [Dumper.Dump] returns that error. +type Dumper struct { + Err error + + mu sync.Mutex + calls int + last map[string]any +} + +// Dump records the call and optionally returns [Dumper.Err]. +func (d *Dumper) Dump(_ context.Context, values *map[string]any) error { + d.mu.Lock() + defer d.mu.Unlock() + d.calls++ + if values != nil && *values != nil { + cp := make(map[string]any, len(*values)) + maps.Copy(cp, *values) + d.last = cp + } + return d.Err +} + +// Calls returns how many times Dump has been invoked. +func (d *Dumper) Calls() int { + d.mu.Lock() + defer d.mu.Unlock() + return d.calls +} + +// Last returns a shallow copy of the last Dump values, or nil if none. +func (d *Dumper) Last() map[string]any { + d.mu.Lock() + defer d.mu.Unlock() + return d.last +} + +// FuncCodec implements [codec.Decoder] and [codec.Encoder] with function fields. +// Either field may be nil: nil [FuncCodec.DecodeFunc] is a no-op decode; +// nil [FuncCodec.EncodeFunc] returns an empty byte slice and a nil error. +type FuncCodec struct { + DecodeFunc func(data []byte, v any) error + EncodeFunc func(v any) ([]byte, error) +} + +// Decode implements [codec.Decoder]. +func (c *FuncCodec) Decode(data []byte, v any) error { + if c.DecodeFunc == nil { + return nil + } + return c.DecodeFunc(data, v) +} + +// Encode implements [codec.Encoder]. +func (c *FuncCodec) Encode(v any) ([]byte, error) { + if c.EncodeFunc == nil { + return []byte{}, nil + } + return c.EncodeFunc(v) +} + +// AssertString asserts cfg.String(key) equals want. +func AssertString(t *testing.T, cfg *synthra.Synthra, key, want string) { + t.Helper() + got, err := cfg.String(key) + require.NoError(t, err, "key %q", key) + require.Equal(t, want, got, "key %q", key) +} + +// AssertInt asserts cfg.Int(key) equals want. +func AssertInt(t *testing.T, cfg *synthra.Synthra, key string, want int) { + t.Helper() + got, err := cfg.Int(key) + require.NoError(t, err, "key %q", key) + require.Equal(t, want, got, "key %q", key) +} + +// AssertBool asserts cfg.Bool(key) equals want. +func AssertBool(t *testing.T, cfg *synthra.Synthra, key string, want bool) { + t.Helper() + got, err := cfg.Bool(key) + require.NoError(t, err, "key %q", key) + require.Equal(t, want, got, "key %q", key) +} + +// AssertStringSlice asserts cfg.StringSlice(key) equals want. +func AssertStringSlice(t *testing.T, cfg *synthra.Synthra, key string, want []string) { + t.Helper() + got, err := cfg.StringSlice(key) + require.NoError(t, err, "key %q", key) + require.Equal(t, want, got, "key %q", key) +} + +// AssertDumped asserts [Dumper.Calls] is 1 and [Dumper.Last] equals want +// (shallow map equality). +func AssertDumped(t *testing.T, d *Dumper, want map[string]any) { + t.Helper() + require.Equal(t, 1, d.Calls(), "expected exactly one Dump call") + require.Equal(t, want, d.Last()) +} diff --git a/synthratest/synthratest_test.go b/synthratest/synthratest_test.go new file mode 100644 index 0000000..fd7aef9 --- /dev/null +++ b/synthratest/synthratest_test.go @@ -0,0 +1,243 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !integration + +package synthratest + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopherly.dev/synthra" + "gopherly.dev/synthra/source" +) + +func TestErrSource_loadFails(t *testing.T) { + t.Parallel() + + loadErr := errors.New("source load failed") + src := ErrSource(loadErr) + require.NotNil(t, src) + + cfg := Config(t, synthra.WithSource(src)) + err := cfg.Load(context.Background()) + require.Error(t, err) + assert.ErrorContains(t, err, "source load failed") +} + +func TestErrSource_nilPanics(t *testing.T) { + t.Parallel() + require.Panics(t, func() { ErrSource(nil) }) +} + +func TestDumper_withError(t *testing.T) { + t.Parallel() + + dumpErr := errors.New("dumper write failed") + dumper := &Dumper{Err: dumpErr} + + cfg := Config(t, + synthra.WithSource(source.NewMap(map[string]any{"foo": "bar"})), + synthra.WithDumper(dumper), + ) + require.NoError(t, cfg.Load(context.Background())) + + err := cfg.Dump(context.Background()) + require.Error(t, err) + assert.ErrorContains(t, err, "dumper write failed") +} + +func TestWriteFile_yaml(t *testing.T) { + t.Parallel() + + content := []byte("key: value\nnested:\n num: 42") + path := WriteFile(t, YAML, content) + require.NotEmpty(t, path) + + //nolint:gosec // G304: path is from WriteFile (t.TempDir() + fixed name), not user input + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, content, data) +} + +func TestWriteFile_json(t *testing.T) { + t.Parallel() + + content := []byte(`{"key":"value","nested":{"num":42}}`) + path := WriteFile(t, JSON, content) + require.NotEmpty(t, path) + + //nolint:gosec // G304: path is from WriteFile (t.TempDir() + fixed name), not user input + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, content, data) +} + +func TestWriteFile_toml(t *testing.T) { + t.Parallel() + + content := []byte("key = \"value\"\n[nested]\nnum = 42") + path := WriteFile(t, TOML, content) + require.NotEmpty(t, path) + + //nolint:gosec // G304: path is from WriteFile (t.TempDir() + fixed name), not user input + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, content, data) +} + +func TestLoadFile_yaml(t *testing.T) { + t.Parallel() + + content := []byte("app: myapp\nport: 8080") + cfg := LoadFile(t, YAML, content) + require.NotNil(t, cfg) + s, err := cfg.String("app") + require.NoError(t, err) + assert.Equal(t, "myapp", s) + p, err := cfg.Int("port") + require.NoError(t, err) + assert.Equal(t, 8080, p) +} + +func TestLoadFile_json(t *testing.T) { + t.Parallel() + + content := []byte(`{"app":"myapp","port":8080}`) + cfg := LoadFile(t, JSON, content) + require.NotNil(t, cfg) + s, err := cfg.String("app") + require.NoError(t, err) + assert.Equal(t, "myapp", s) + p, err := cfg.Int("port") + require.NoError(t, err) + assert.Equal(t, 8080, p) +} + +func TestLoadFile_toml(t *testing.T) { + t.Parallel() + + content := []byte("app = \"myapp\"\nport = 8080") + cfg := LoadFile(t, TOML, content) + require.NotNil(t, cfg) + s, err := cfg.String("app") + require.NoError(t, err) + assert.Equal(t, "myapp", s) + p, err := cfg.Int("port") + require.NoError(t, err) + assert.Equal(t, 8080, p) +} + +func TestAssertString_int_bool_slice(t *testing.T) { + t.Parallel() + + cfg := Load(t, map[string]any{ + "foo": "bar", + "num": 42, + "on": true, + "tags": []string{"a", "b"}, + }) + AssertString(t, cfg, "foo", "bar") + AssertInt(t, cfg, "num", 42) + AssertBool(t, cfg, "on", true) + AssertStringSlice(t, cfg, "tags", []string{"a", "b"}) +} + +func TestGet_inlineEqual(t *testing.T) { + t.Parallel() + + cfg := Load(t, map[string]any{"foo": "bar", "num": 42}) + require.Equal(t, "bar", cfg.Get("foo")) + require.Equal(t, 42, cfg.Get("num")) +} + +func TestFuncCodec(t *testing.T) { + t.Parallel() + + t.Run("Decode and Encode succeed", func(t *testing.T) { + t.Parallel() + + decodeCalled := false + encodeCalled := false + mock := &FuncCodec{ + DecodeFunc: func(data []byte, v any) error { + decodeCalled = true + return nil + }, + EncodeFunc: func(v any) ([]byte, error) { + encodeCalled = true + return []byte("encoded"), nil + }, + } + + var dst map[string]any + err := mock.Decode([]byte("input"), &dst) + require.NoError(t, err) + assert.True(t, decodeCalled) + + out, err := mock.Encode(map[string]any{"x": 1}) + require.NoError(t, err) + assert.True(t, encodeCalled) + assert.Equal(t, []byte("encoded"), out) + }) + + t.Run("Decode returns error", func(t *testing.T) { + t.Parallel() + + decodeErr := errors.New("decode failed") + mock := &FuncCodec{ + DecodeFunc: func([]byte, any) error { return decodeErr }, + } + + var dst map[string]any + err := mock.Decode([]byte("x"), &dst) + require.Error(t, err) + assert.ErrorContains(t, err, "decode failed") + }) + + t.Run("Encode returns error", func(t *testing.T) { + t.Parallel() + + encodeErr := errors.New("encode failed") + mock := &FuncCodec{ + EncodeFunc: func(any) ([]byte, error) { return nil, encodeErr }, + } + + _, err := mock.Encode(map[string]any{}) + require.Error(t, err) + assert.ErrorContains(t, err, "encode failed") + }) + + t.Run("nil EncodeFunc returns empty bytes", func(t *testing.T) { + t.Parallel() + mock := &FuncCodec{DecodeFunc: func([]byte, any) error { return nil }} + out, err := mock.Encode(nil) + require.NoError(t, err) + assert.Equal(t, []byte{}, out) + }) +} + +func TestConfig_example(t *testing.T) { + t.Parallel() + cfg := Config(t, + synthra.WithSource(source.NewMap(map[string]any{"k": "v"})), + ) + require.NoError(t, cfg.Load(t.Context())) + AssertString(t, cfg, "k", "v") +}