惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
Schneier on Security
Help Net Security
Help Net Security
C
CXSECURITY Database RSS Feed - CXSecurity.com
Attack and Defense Labs
Attack and Defense Labs
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Latest news
Latest news
PCI Perspectives
PCI Perspectives
W
WeLiveSecurity
Spread Privacy
Spread Privacy
O
OpenAI News
T
Threat Research - Cisco Blogs
S
Secure Thoughts
Cyberwarzone
Cyberwarzone
S
Security Affairs
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
TaoSecurity Blog
TaoSecurity Blog
V2EX - 技术
V2EX - 技术
V
Vulnerabilities – Threatpost
A
Arctic Wolf
P
Proofpoint News Feed
P
Palo Alto Networks Blog
C
CERT Recently Published Vulnerability Notes
N
News | PayPal Newsroom
C
Cisco Blogs
T
Troy Hunt's Blog
Security Archives - TechRepublic
Security Archives - TechRepublic
L
LINUX DO - 热门话题
Cloudbric
Cloudbric
L
Lohrmann on Cybersecurity
Cisco Talos Blog
Cisco Talos Blog
T
Tor Project blog
T
Threatpost
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
S
Security @ Cisco Blogs
G
GRAHAM CLULEY
Security Latest
Security Latest
K
Kaspersky official blog
Forbes - Security
Forbes - Security
AI
AI
Hacker News - Newest:
Hacker News - Newest: "LLM"
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
AWS News Blog
AWS News Blog
H
Help Net Security
N
News and Events Feed by Topic
博客园_首页
Apple Machine Learning Research
Apple Machine Learning Research
量子位
Last Week in AI
Last Week in AI
小众软件
小众软件
V
V2EX

Hacker News: Show HN

PurrrrrFocus: Pomodoro Timer App - App Store Workflow Engine — Multi-Step Orchestration for Bun RapidPhoto: Pro Photo Editor App - App Store GitHub - DheerG/swarms: Achieve extraordinary results with claude code across a variety of tasks SPICE simulation → oscilloscope → verification with Claude Code — Lucas Gerads Show HN: VCoding – A 5 MB native Windows IDE with no dynamic dependencies Show HN: LLMs don't hallucinate because they're bad at math, it's the format GitHub - Agent-FM/agentfm-core: AgentFM is a peer-to-peer network that turns everyday computers into a decentralized AI supercomputer. AgentFM lets you run massive AI workloads directly across a global mesh of idle CPUs and GPUs. Show HN: Tracking Top US Science Olympiad Alumni over Last 25 Years GitHub - Potarix/agent-hub: One place to talk to all your agents Show HN: Runtime security for AI agents(injection,tool abuse, data exfiltration) GitHub - dubeyKartikay/lazyspotify: Terminal Spotify client for macOS and Linux GitHub - the-banana-tool/king-louie: Easy to use GUI Personal AI Assistant. Win/Linux/Mac. Show HN I made my vacation rental bookable by AI agents–no Airbnb, 0% commission GitHub - basteez/jsf-autoreload: maven plugin to enable hot reload on jsf projects uvm32/hosts/host-gdbstub at main · ringtailsoftware/uvm32 GitHub - labsai/EDDI: Config-driven engine that turns JSON into production-grade AI agents. Multi-agent orchestration, 12+ LLM providers, MCP/A2A protocols, RAG, persistent memory, and enterprise compliance (EU AI Act, GDPR, HIPAA). Built on Quarkus. GitHub - glitchnsec/fortyone-oss: AI Executive Assistant Platform Quickstart | Alien GitHub - muxshed/shed: One stream in, or many. Every destination, simultaneously. No cloud middleman, no per-channel fees, no limits. GitHub - ocrbase-hq/ocrbase: 📄 PDF/IMG ->.MD/JSON Document OCR API for PaddleOCR and GLMOCR. Self-hostable. GitHub - impactjo/home-memory: MCP server that lets your AI assistant remember everything about your home. GitHub - Sets88/dbcls: DbCls is a powerful terminal database client that supports various databases GitHub - neptun2000/heor-agent-mcp GitHub - SeanFDZ/macmind: Single-layer transformer in HyperTalk for the classic Macintosh RollQuation: Math Puzzles - Apps on Google Play GitHub - dropbox/witchcraft Show HN: Agent-cache – Multi-tier LLM/tool/session caching for Valkey and Redis GitHub - opentalon/opentalon: OpenTalon is an open-source platform built from the ground up in Go as a robust alternative to OpenClaw LinkedIn™ 职位抓取工具 - Chrome 应用商店 GitHub - EdoardoBambini/Agent-Armor-Iaga: AI agents are getting tool access — shell, file system, databases, APIs, secrets. But **nobody is governing what they actually do with it**. Frameworks like LangChain, CrewAI, AutoGen, and Claude Code give agents the power to execute. Agent Armor gives you the power to control, audit, and approve every single action before it happens. HN Vibes — Week 15, Apr 7–13 2026 GitHub - chojs23/ec: Easy terminal-native 3-way git mergetool vim-like workflow GitHub - SethPyle376/hiraeth: Local AWS emulator focused on fast integration testing, with SQS support, SQLite-backed state, and a debug-friendly web UI. GitHub - JakOb-dotcom/cloud-sandbox-security-analysis: Technical analysis and Proof of Concept (PoC) regarding environment variable exfiltration in containerized cloud sandboxes via side-channel data leaks. Springboards - Flint Alpha Show HN: A simpler coding agent harness GitHub - audiodude/sudomake-friends GitHub - 256thFission/mini-mythos: OSS clone of Anthropic’s Mythos harness to locate C/C++ memory vulnerabilities Show HN: OpenParallax: OS-level privilege separation for AI agent execution Hacker News Sorted - Chrome 应用商店 Show HN: How to Install Docker on Ubuntu 24.04 LTS: Complete 2026 Guide GitHub - himanshudongre/smriti GitHub - sverrirsig/claude-control: macOS desktop dashboard for monitoring and managing multiple Claude Code sessions GitHub - ory/dockertest: Write better integration tests! Dockertest helps you boot up ephermal docker images for your Go tests with minimal work. Chiral - Chrome 应用商店 Show HN: Two Claudes collaborating through shared memory on a $100 mini-PC GitHub - pmichaillat/latex-cv: Minimalist LaTeX template for academic CVs GitHub - oguzbilgic/posse: A web UI for Anthropic Managed Agents. GitHub - sshiraz/depsly: Dependency risk analysis tool for npm packages ABI Add safari/agent-harness — Safari browser automation via safari-mcp by achiya-automation · Pull Request #212 · HKUDS/CLI-Anything GitHub - Halfblood-Prince/trustcheck: Verify PyPI package attestations and improve Python supply-chain security GitHub - oguzbilgic/kern-ai: Agents that do the work and show it. GitHub - bruits/satteri: High-performance Markdown and MDX processing for the JavaScript ecosystem GitHub - tylergibbs1/feedstock: High-performance web crawler and scraper for TypeScript, powered by Bun and Playwright GitHub - Grimm67123/grimmbot: The self-improving sandboxed and open-source AI agent. With persistent memory and scheduling. GitHub - whitevanillaskies/whitebloom: Local whiteboard that blooms. GitHub - hwdsl2/docker-whisper: Docker image for a self-hosted Whisper speech-to-text server with speaker diarization and OpenAI-compatible transcription and translation APIs. Powered by faster-whisper. Supports all Whisper models, NVIDIA GPU (CUDA) acceleration, JSON/SRT/VTT output, SSE streaming, offline mode, and multi-arch (amd64, arm64). GitHub - yisding/reviewwiggum GitHub - MarwanAlsoltany/serrors: Structured errors for Go: sentinel hierarchies, typed data, custom formatting, and slog integration. GitHub - soatok/age-php GitHub - Luthiraa/markitme GitHub - stagas/rtdiff: realtime git diff gui and AI-assisted commits GitHub - tombedor/excalicharts GitHub - wh1le/excalidraw-edit: Open and edit .excalidraw files from the terminal. Offline, auto-saves to disk. MalExt Sentry - Malicious Extension Scanner - Chrome 应用商店 GitHub - syi0808/asciianimesvg: Generate animated ASCII art SVGs from text. CLI, Rust library, WASM, and web editor. GitHub - zaina-ml/ml_forge: A visual-based graph node editor for training computer vision models. GitHub - anakin87/llm-rl-environments-lil-course: 🌱 A little course on Reinforcement Learning Environments for evaluating and training Language Models GitHub - takaakit/superpowers-uml: Superpowers-UML modifies Superpowers to ensure a software development workflow in which AI agents design through UML modeling. AdriByte Studio - Sviluppo Web e Soluzioni Digitali GitHub - chouligi/angel-copilot: Your personalized Angel Investment Advisor Show HN: MoodSense AI (ML and FastAPI and Gradio, Deployed on Hugging Face) Moodsense Ai - a Hugging Face Space by aman179102 GitHub - agenteractai/lodmem: Level Of Detail Context Management for Agents GitHub - ostefani/subnetlens: A fast, concurrent network scanner with a TUI and plain-text CLI, built in Go. It discovers live hosts on your network, scans their open ports, resolves hostnames, and fingerprints operating systems—delivered. Cyber Pulse: Agentic Intel - Apps on Google Play Whisper API: Self-Hostable Speech to Text Transcription The Agent-Web Protocol Stack: A Research Thesis GitHub - msmarkgu/RelayFreeLLM: A restful API designed to route user prompts to various AI model providers. Show HN: Provepy – A Python decorator that proves your code using Lean and LLMs Show HN: Pardonned.com – A searchable database of US Pardons GitHub - patrickdappollonio/dux: Dux is a terminal UI that lets you run multiple AI coding agents side by side, each in its own git worktree, with full companion terminals, macros, commit generation, and a command palette that knows more tricks than you do. kMC Crystal Simulator Show HN: HyperFlow – A self-improving agent framework built on LangGraph GitHub - stef41/vibescore: 🎵 Grade your vibe-coded project. One command, instant letter grade across security, quality, dependencies, and testing. GitHub - stef41/lmscan: 🔍 Detect AI-generated text and fingerprint which LLM wrote it. Open-source GPTZero alternative. Zero dependencies, works offline. imgur.com GitHub - visionscaper/collabmem: Enabling long-term collaboration with Agentic AI - building up episodic and world model memory over time with in-context awareness 在 Steam 上购买 FriedrichAI: Offline AI 立省 10% GitHub - atripati/ark: AI Runtime Kernel — a context operating system for AI agents. Eliminates tool bloat, loads only what’s needed, and gives LLMs their reasoning space back. GitHub - nowork-studio/toprank: Open-source Claude Code skills for SEO, SEM, Google Ads GitHub - tacomanator/sash: Lightweight macOS menu bar app for reliably cycling through windows of the current application. Appents | Social Media Management for Product-First Teams GitHub - pnhoang/youtube-spam-blocker: Automatically detects and hides spam messages in YouTube Live chat. Set rate limits, keyword filters, and block repeat offenders. GitHub - decisionnode/DecisionNode: CLI + Local MCP - A shared structured memory store across Claude Code, Cursor, Windsurf, Antigravity, and every MCP client. Semantically queryable. GitHub - AvaCodeSolutions/django-email-learning: An open source Django app for creating email-based learning platforms with IMAP integration and React frontend components. The $100K Gap in Kubernetes Security Tooling Function Calling Harness: From 6.75% to 100%
GitHub - gopherly/synthra: Go package that merges files, env vars, Consul, and custom sources into one typed config.
atkrad · 2026-05-20 · via Hacker News: Show HN

CI codecov Go Reference Go Report Card License Slack

From many sources, one config.

Synthra is a Go package 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."

go get gopherly.dev/synthra

Requires Go 1.26 or later.

import "gopherly.dev/synthra"

How it works

flowchart LR
    S1[File] --> Merge
    S2[Env] --> Merge
    S3[Consul] --> Merge
    S4[Custom] --> Merge
    Merge --> P1

    subgraph Pipeline ["pipeline steps (run in registration order)"]
        direction LR
        P1["Step 0<br>schema / transform / validator"] --> P2
        P2["Step 1<br>..."] --> P3
        P3["Step N<br>..."]
    end

    P3 --> Bind["Bind to struct"]
    Bind --> Ready["Synthra ready"]
    Ready --> Read["Get / String / Int / ..."]
    Ready --> Dump["Dump"]

    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 P1 fill:#fef3c7,stroke:#f59e0b,color:#78350f
    style P2 fill:#fef3c7,stroke:#f59e0b,color:#78350f
    style P3 fill:#fef3c7,stroke:#f59e0b,color:#78350f
    style Pipeline fill:#fffbeb,stroke:#f59e0b,stroke-dasharray:5 5,color:#92400e
    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
Loading

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 variables override files cleanly across environments.
  • Automatic format detection from extension (.yaml, .json, .toml).
  • JSON Schema defaults fill missing keys automatically.
  • Pipeline processing: schema steps, transforms, and validators run in registration order for full flexibility.
  • Dynamic schema selection with WithJSONSchemaFunc based on a value inside the config.
  • Two-phase validation: validate before substitution and again after.
  • Struct binding with type conversion, defaults, and validation.
  • Case-insensitive keys with dot notation (server.port).
  • Safe for concurrent use.
  • Small core, optional extras. Consul is the only heavy dependency, and you only touch it if you need it.
  • A synthratest helper package for tests.

Contents

  1. How it works
  2. Quick start
  3. Sources
  4. Formats
  5. Struct binding
  6. Default values
  7. JSON Schema defaults
  8. Pipeline
  9. Pipeline callbacks and Configuration/Configurable
  10. Validation
  11. Reading values
  12. Merge order and precedence
  13. Case insensitivity, casing, and dot notation
  14. Environment variable naming
  15. Dumping configuration
  16. Testing helpers
  17. Custom sources and codecs
  18. Error handling
  19. Thread safety
  20. Examples
  21. License
  22. Contributing

Quick start

Create a config.yaml file:

server:
  host: "localhost"
  port: 8080
debug: true

Then load it:

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 and an error. Synthra ships several built-in sources.

File with automatic format detection

The format comes from the file extension. Supported extensions: .yaml, .yml, .json, .toml.

synthra.WithFile("config.yaml")
synthra.WithFile("config.json")
synthra.WithFile("config.toml")

Paths support shell-style environment variable expansion: ${VAR} or $VAR.

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.

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).

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.

APP_SERVER_PORT=8080 becomes server.port = "8080".

See Environment variable naming for the full rules.

In-memory content

Pass raw bytes and a decoder. Good for baked-in defaults.

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.

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:

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:

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:

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:

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):

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.

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:

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.

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):

defaults := []byte(`server: { port: 3000 }`)

synthra.MustNew(
    synthra.WithContent(defaults, codec.YAML),
    synthra.WithFile("config.yaml"),
    synthra.WithEnv("APP_"),
)

JSON Schema defaults

When you pass a schema with WithJSONSchema, Synthra automatically extracts every "default" value declared in the schema and applies it to any key that is missing from the loaded configuration. This happens after sources are merged and before validation runs, so the schema validator always sees a fully populated map.

schema := []byte(`{
    "type": "object",
    "properties": {
        "port":      {"type": "integer", "default": 8080},
        "log_level": {"type": "string",  "default": "info",
                      "enum": ["debug", "info", "warn", "error"]}
    }
}`)

s := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithJSONSchema(schema),
)
// If config.yaml omits "port" and "log_level", they are set to 8080 and "info"
// before validation. Values present in config.yaml are never overridden.

Defaults are applied at every level of nesting, including patternProperties. For dynamic key names (like a map of named components), Synthra applies the patternProperties defaults to every existing key that matches the pattern:

schema := []byte(`{
    "properties": {
        "components": {
            "patternProperties": {
                "^[a-z0-9-]+$": {
                    "properties": {
                        "role":     {"type": "string",  "default": "service"},
                        "replicas": {"type": "integer", "default": 1}
                    }
                }
            }
        }
    }
}`)

// config.yaml:
//   components:
//     web:
//       image: nginx
//     worker:
//       image: my-app
//       replicas: 3
//
// After Load:
//   components.web.role     => "service"  (from schema default)
//   components.web.replicas => 1          (from schema default)
//   components.worker.role  => "service"  (from schema default)
//   components.worker.replicas => 3       (from config.yaml, not overridden)

Pipeline

After sources are merged, Synthra executes a pipeline of steps in the order they were registered. Each call to a pipeline option adds one step to the list. Steps run strictly in registration order; there is no implicit ordering between schema, transform, and validator steps.

Pipeline step options:

Option What it does
WithJSONSchema(bytes) Applies schema defaults then validates at this point in the pipeline. Schema bytes are validated at construction.
WithJSONSchemaFunc(selector) Same as WithJSONSchema but schema bytes come from a callback that receives the current values. Use this when the schema depends on a value inside the config (e.g. apiVersion).
WithTransform(fn) Applies an arbitrary map mutation at this point.
WithEnvSubst(r) Expands ${VAR} placeholders in all string values using a single Resolver. Compose multiple sources with .Or. Sugar over WithTransform.
WithValidator(fn) Runs a read-only check. Does not modify values.

Because steps are ordered, you can place a schema before a transform, a transform before a validator, or anything else you need.

Dynamic schema selection

Use WithJSONSchemaFunc when the schema depends on a value inside the config itself. The most common case is an apiVersion field:

cfg := synthra.MustNew(
    synthra.WithFile("manifest.yaml"),
    synthra.WithJSONSchemaFunc(func(_ context.Context, v *synthra.Configurable) ([]byte, error) {
        version, err := v.String("apiVersion")
        if err != nil || version == "" {
            return nil, errors.New("apiVersion is required")
        }
        return schemaRegistry.Get(version)  // your own lookup
    }),
)

Multiple WithJSONSchema and WithJSONSchemaFunc calls are fully supported; each adds an independent schema step at the point it was registered.

Two-phase validation

Register a schema step before substitution to validate raw fields, then register another after substitution to validate the final form:

cfg := synthra.MustNew(
    synthra.WithFile("manifest.yaml"),
    // Step 1: validate the "environments" block on raw values.
    synthra.WithJSONSchemaFunc(func(_ context.Context, _ *synthra.Configurable) ([]byte, error) {
        return environmentsSchema, nil
    }),
    // Step 2: expand ${VAR} placeholders from OS environment.
    synthra.WithEnvSubst(synthra.FromEnv()),
    // Step 3: validate the fully-substituted manifest.
    synthra.WithJSONSchemaFunc(func(_ context.Context, _ *synthra.Configurable) ([]byte, error) {
        return manifestSchema, nil
    }),
)

See examples/multi-schema for a runnable demonstration.

Transforms

WithTransform registers a function that processes the merged configuration map at the point it was registered. Multiple transforms run in registration order.

s := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithTransform(func(_ context.Context, v *synthra.Configurable) error {
        if level := v.StringOr("logLevel", ""); level != "" {
            return v.Set("logLevel", strings.ToLower(level))
        }
        return nil
    }),
    synthra.WithJSONSchema(schema), // validates the normalized values
)

WithEnvSubst is a convenience transform that expands POSIX-style ${VAR} placeholders in all string values. It supports defaults (${VAR:-fallback}), uppercase conversion (${VAR^^}), prefix stripping (${VAR#prefix}), and more.

Variable lookup is handled by a single Resolver. Compose multiple sources with .Or. The first resolver that reports found wins (highest priority first):

// OS environment only
s := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnvSubst(synthra.FromEnv()),
)

// Static map
s = synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithJSONSchema(schema),
    synthra.WithEnvSubst(synthra.FromMap(map[string]string{
        "ENV":    "production",
        "REGION": "eu-west-1",
    })),
)
// If config.yaml has: envFile: ".env.${ENV}"
// After Load:         envFile => ".env.production"

Layer multiple resolvers for priority-based substitution using .Or. The first resolver to find a given variable name wins (highest priority first):

envFile, err := synthra.FromEnvFile(".env")
if err != nil {
    log.Fatal(err)
}

s := synthra.MustNew(
    synthra.WithFile("deployah.yaml"),
    synthra.WithEnvSubst(
        synthra.FromEnv().Prefix("DPY_VAR_").  // highest: prefixed OS env
            Or(envFile).                         // middle:  .env file
            Or(synthra.FromMap(manifestVars)),   // lowest:  static defaults
    ),
)
// config.yaml: port: ${PORT:-3000}
// If DPY_VAR_PORT=9090 is set, port becomes "9090".
// If DPY_VAR_PORT is not set but PORT is in the .env file, that value is used.
// If neither is set, the ${VAR:-default} fallback provides "3000".

Each variable lookup walks the chain from left to right and stops at the first resolver that reports found:

flowchart LR
    Var["${PORT:-3000}"] --> R1
    Out["resolved value"]

    subgraph Chain ["resolver chain (first match wins)"]
        direction LR
        R1["FromEnv().Prefix(DPY_VAR_)"] -->|"not found"| R2
        R2["FromEnvFile(.env)"] -->|"not found"| R3
        R3["FromMap(defaults)"] -->|"not found"| Fallback["inline default"]
    end

    R1 -->|found| Out
    R2 -->|found| Out
    R3 -->|found| Out
    Fallback --> Out

    style Var fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
    style R1 fill:#fef3c7,stroke:#f59e0b,color:#78350f
    style R2 fill:#fef3c7,stroke:#f59e0b,color:#78350f
    style R3 fill:#fef3c7,stroke:#f59e0b,color:#78350f
    style Chain fill:#fffbeb,stroke:#f59e0b,stroke-dasharray:5 5,color:#92400e
    style Fallback fill:#fef3c7,stroke:#f59e0b,color:#78350f
    style Out fill:#d1fae5,stroke:#10b981,color:#064e3b
Loading

The available resolver constructors are:

  • synthra.FromMap(m): looks up variables from a map[string]string
  • synthra.FromEnv(): looks up variables using os.LookupEnv (reads live env at Load time)
  • synthra.FromEnvFile(path): parses a .env file eagerly and returns a map-backed resolver; returns an error if the file is missing or malformed
  • .Prefix(prefix): method on any Resolver that prepends a namespace prefix to each lookup (e.g. FromEnv().Prefix("APP_") resolves PORT from APP_PORT)
  • .Or(fallbacks...): method on any Resolver that chains fallbacks with first-match-wins semantics

Load a .env file and combine it with OS env (OS env wins via first match):

envFile, err := synthra.FromEnvFile(".env")
if err != nil {
    log.Fatal(err)
}

s := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnvSubst(synthra.FromEnv().Or(envFile)),
)

Use WithEnvSubstFunc when the resolver depends on values already loaded from sources. For example, a .env file path stored inside the config file:

s := synthra.MustNew(
    synthra.WithFile("config.yaml"),
    synthra.WithEnvSubstFunc(func(_ context.Context, v *synthra.Configurable) (synthra.Resolver, error) {
        envPath := v.StringOr("envfile", "")
        if envPath == "" {
            return synthra.FromEnv(), nil
        }
        envFile, err := synthra.FromEnvFile(envPath)
        if err != nil {
            return nil, err
        }
        // OS env takes priority over the .env file.
        return synthra.FromEnv().Or(envFile), nil
    }),
)

WithEnv and WithEnvSubst solve different problems:

  • WithEnv is a source. It reads environment variables and adds them to the config map. For example, APP_SERVER_PORT=8080 becomes server.port. Use this when you want env vars to be config keys.
  • WithEnvSubst is a transform. It expands ${VAR} placeholders that are already present in string values loaded from files or other sources. Use this when your config files contain placeholder strings that should be filled from the environment or a map.

Both can be used together. They do not overlap.

WithEnvSubst also works with patternProperties defaults. If the schema default for a field is ".env.${NAME}", the substitution runs after the default is applied and fills in the placeholder.

Pipeline callbacks and Configuration/Configurable

WithTransform, WithEnvSubstFunc, WithValidator, and WithJSONSchemaFunc receive typed wrappers instead of a raw map[string]any. The wrappers give you safe, case-insensitive, typed access.

  • WithTransform and WithEnvSubstFunc receive *synthra.Configurable — the mutable view. Use Set, Delete, Walk, and Raw to modify the configuration.
  • WithValidator and WithJSONSchemaFunc receive *synthra.Configuration — the read-only view. Enforced at the type level: validators cannot mutate the map.

Configurable embeds Configuration, so all read methods are available on both types.

All callbacks receive a context.Context as the first argument, enabling cancellation, timeouts, and tracing.

Why typed wrappers

Direct map access is case-sensitive at the Go language level. If your YAML says apiVersion but the merged map ends up storing it under a slightly different casing, values["apiVersion"] returns nil. The wrappers fix that. All methods are case-insensitive.

Reading

c.Get("metadata.name")         // any, case-insensitive
c.Has("server.tls.enabled")    // bool
c.String("apiVersion")         // (string, error)
c.IntOr("server.port", 8080)   // int with default

Writing (only on *Configurable)

_ = v.Set("metadata.region", "eu-west-1") // creates intermediate maps
v.Delete("debug.experimental")

Walking the tree (only on *Configurable)

v.Walk(func(path string, val any) (any, bool) {
    if s, ok := val.(string); ok && strings.HasPrefix(s, "${") {
        return strings.TrimPrefix(s, "${"), true
    }
    return val, false
})

Array-of-object navigation

When a key holds a slice of objects (e.g. an "environments" array), use the built-in accessors to avoid .([]any) / .(map[string]any) type assertions:

// Count elements
n := v.SliceLen("environments")

// Iterate map elements (non-map elements are skipped)
for i, e := range v.EachMap("environments") {
    fmt.Println(i, e.StringOr("name", ""))
}

// Find by field value (case-insensitive)
env := v.Find("environments", "name", "prod")
if env == nil {
    return errors.New("environment prod not found")
}

// Find with a predicate (short-circuits on first match)
env = v.FindFunc("environments", func(e *synthra.Configurable) bool {
    return e.IntOr("port", 0) == 443
})

Elements returned by Find and EachMap on *Configurable share the parent's underlying map: mutations via Set/Delete reach back to the parent.

Escape hatch

When you must hand the underlying map to code that expects a plain map[string]any, call v.Raw() on *Configurable. Mutations on the returned map are visible through the same *Configurable.

Map stage vs binding stage

WithTransform, WithValidator, WithEnvSubstFunc, and WithJSONSchemaFunc run at the map stage, before binding.

OnBound[T] is a binding-scoped option that goes inside WithBinding[T]. It runs at the binding stage: after the bound struct is decoded and defaults applied, but before its Validate() method.

synthra.WithFile("config.yaml"),
synthra.WithTransform(func(_ context.Context, v *synthra.Configurable) error {
    if v.StringOr("env", "dev") == "prod" {
        return v.Set("logging.level", "warn")
    }
    return nil
}),
synthra.WithBinding(&app,
    synthra.OnBound(func(a *App) error {
        a.Logging.Level = strings.ToLower(a.Logging.Level)
        return nil
    }),
),

Compile-time safety for OnBound

Because OnBound[T] is a sub-option of WithBinding[T], Go infers the same T for both. If the closure type does not match the binding target, you get a compile error, not a runtime panic:

var server Server
synthra.WithBinding(&server,
    synthra.OnBound(func(a *App) error { ... }),  // compile error
)

Here is the full sequence when Load runs:

flowchart TB
    Sources["sources merged (last wins)"] --> MapStage

    subgraph MapStage ["map stage"]
        direction TB
        Steps["pipeline steps in order<br>schema / transform / envsubst / validator"]
    end

    MapStage --> Bind["bind into struct<br>mapstructure + default tags"]
    Bind --> BindStage

    subgraph BindStage ["binding stage"]
        direction TB
        Hooks["OnBound[T] hooks (in order)"] --> Val["Validate() if implemented"]
    end

    BindStage --> Ready["ready"]

    style Sources fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
    style Steps fill:#fef3c7,stroke:#f59e0b,color:#78350f
    style MapStage fill:#fffbeb,stroke:#f59e0b,stroke-dasharray:5 5,color:#92400e
    style Bind fill:#fef3c7,stroke:#f59e0b,color:#78350f
    style Hooks fill:#fef3c7,stroke:#f59e0b,color:#78350f
    style Val fill:#fef3c7,stroke:#f59e0b,color:#78350f
    style BindStage fill:#fffbeb,stroke:#f59e0b,stroke-dasharray:5 5,color:#92400e
    style Ready fill:#d1fae5,stroke:#10b981,color:#064e3b
Loading

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.

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 fills in "default" values, then validates the merged map before binding.

schema := []byte(`{
    "type": "object",
    "required": ["service", "port"],
    "properties": {
        "service": {"type": "string", "minLength": 1},
        "port":    {"type": "integer", "minimum": 1, "maximum": 65535, "default": 8080}
    }
}`)

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. See JSON Schema defaults for details on default application.

3. Custom validator function

Use WithValidator for cross-field rules or any logic that does not fit a schema.

synthra.WithValidator(func(_ context.Context, c *synthra.Configuration) error {
    if !c.Has("server.tls") {
        return nil
    }
    if enabled := c.BoolOr("server.tls.enabled", false); enabled {
        if !c.Has("server.tls.cert") || !c.Has("server.tls.key") {
            return errors.New("tls.cert and tls.key are required when tls.enabled is true")
        }
    }
    return nil
})

To report multiple errors at once in a single validator, use errors.Join:

synthra.WithValidator(func(_ context.Context, c *synthra.Configuration) error {
    var errs []error
    if c.StringOr("app.env", "") == "" {
        errs = append(errs, errors.New("app.env is required"))
    }
    if c.IntOr("port", 0) < 1 {
        errs = append(errs, errors.New("port must be positive"))
    }
    return errors.Join(errs...)
})

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.

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.

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.

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 for the full list.

Generic Get and GetOr

For type-safe access with one function, use the generic helpers:

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 the value as any. It returns nil when the key is missing.

v := s.Get("server.port")  // any

Values() returns a copy of the merged map. Treat it as read-only.

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.

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, casing, and dot notation

Synthra keeps the casing your config sources use. If your file says apiVersion, the loaded map will have apiVersion too. Only the matching is case-insensitive: you can read the same key as apiVersion or APIVERSION and get the same value.

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.

The one exception: environment variables

Environment variables are uppercase by convention (APP_API_VERSION), so the env source lowercases them to produce a nested map. A WithEnv("APP_") source always contributes lowercase keys like apiversion. When env meets another source that already has the same key in a different casing, the case-insensitive merge keeps the first source's casing and overrides only the value. So if your YAML says apiVersion: v1 and APP_APIVERSION=v2 is set, the final map has apiVersion: v2.

Two sources with different casing

When two non-env sources use different casings for the same key, the first source wins for the name and the last source wins for the value. So if base.yaml has ApiVersion: v1 and override.yaml has apiVersion: v2, the final map looks like ApiVersion: v2. The typo in the base file is preserved.

To avoid that, register a JSON Schema. Before validation runs, Synthra renames any case-different keys in the data to match the schema's "properties" declarations. So ApiVersion: v2 becomes apiVersion: v2 if your schema says "properties": {"apiVersion": ...}.

# base.yaml -> ApiVersion: v1
# override.yaml -> apiVersion: v2
# result: ApiVersion: v2  (first writer's casing wins)

# Same files, with a schema declaring apiVersion:
# result: apiVersion: v2  (schema is the authority)

# config.yaml -> apiVersion: v1
# APP_APIVERSION=v2
# result: apiVersion: v2  (YAML casing wins, env overrides value)

patternProperties and additionalProperties dynamic keys are not renamed by the schema. Keys inside list elements are only renamed when the schema declares an items object for that list.

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.

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:

synthra.WithFileDumperAs("output", codec.JSON)

You can also write your own dumper by implementing the Dumper interface and passing it with WithDumper.

type Dumper interface {
    Dump(ctx context.Context, values *map[string]any) error
}

Testing helpers

The synthratest package provides helpers for tests.

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:

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):

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:

type ConfigError struct {
    Op   string  // "new", "load", "dump", or "get"
    Path string  // where the error happened (source index, field, step index, ...)
    Err  error   // the underlying cause
}

Pipeline step errors use the path format "step[N]:kind" where N is the zero-based step index and kind is "schema", "transform", or "validator". For example, "step[0]:schema" means the first registered schema step failed.

Use errors.As to read the operation:

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:

_, 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 are responsible for synchronizing access yourself.

Examples

The examples/ folder has small, runnable programs. Each one has its own README and tests.

Folder Topic
basic YAML file and struct binding
webapp YAML defaults plus WEBAPP_* overrides, binding, and Validate
testing synthratest.Config and source.NewMap
schema WithJSONSchema defaults and patternProperties
casing Case-insensitive merge and schema as casing authority
hooks WithTransform, WithValidator, and OnBound[T] in one pipeline
codecs WithFileAs (JSON, TOML) and WithFileDumperAs (YAML dump)
envsubst-layered Three-layer Resolver.Or precedence
multi-schema Two-phase validation with WithJSONSchemaFunc and EachMap
consul Optional Consul source via WithIf

Run them all with:

License

Synthra is released under the Apache License 2.0.

Community

Join #gopherly on the Gophers Slack for discussion and updates.

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 and check formatting.
  • nix run .#fmt to auto-fix formatting.
  • nix run .#test-unit to run unit tests.