10801 lines
621 KiB
JSON
10801 lines
621 KiB
JSON
{
|
||
"schema": "einherjar.mcp/index/v1",
|
||
"framework": "einherjar",
|
||
"builtAt": "2026-08-08T20:08:14.25644726Z",
|
||
"modules": [
|
||
{
|
||
"name": "auth",
|
||
"importPath": "code.nochebuena.dev/einherjar/auth",
|
||
"purpose": "Not every warrior who knocks at the gate deserves to pass. The Valkyries choose.",
|
||
"doc": "Package auth provides provider-agnostic HTTP authentication and authorization\nmiddleware for the Einherjar framework.\n\nauth absorbs two micro-lib packages:\n - httpauth → sub-package authmw (middleware + identity enrichment)\n - rbac → sub-package rbac (permission provider implementations)\n\nTypes that cross the full dependency graph (Identity, Permission, PermissionMask,\nPermissionProvider) live in contracts/security, not here. This module provides\nimplementations and middleware, not type definitions.\n\n# Sub-packages\n\n[authmw] — HTTP middleware layer. Three functions compose the full auth chain:\n\n - [authmw.SetTokenData] — integration contract called by provider packages\n (auth-jwt, auth-firebase) after token verification.\n - [authmw.EnrichmentMiddleware] — converts uid+claims into a security.Identity\n and stores it in context. The application provides the [authmw.IdentityEnricher]\n implementation that loads user data.\n - [authmw.AuthzMiddleware] — per-route permission gate. Takes a\n [security.PermissionProvider] and the required permission for the route.\n\n[rbac] — permission resolution. Three provider implementations satisfy\n[security.PermissionProvider]:\n\n - [rbac.NewClaimsPermissionProvider] — reads pre-computed bitmasks from JWT\n claims. Zero DB calls. Single-tenant fast-path.\n - [rbac.NewCachedPermissionProvider] — wraps any provider with a TTL cache.\n Cache keys are automatically scoped by TenantID when present.\n - [rbac.NewChainPermissionProvider] — tries providers in order; returns the\n first non-zero mask. Typical: claims fast-path → cached DB fallback.\n\n# Wiring Example\n\n\tenricher := userservice.NewIdentityEnricher(userRepo)\n\n\tpermissions := rbac.NewChainPermissionProvider(\n\t rbac.NewClaimsPermissionProvider(\"perms\", authmw.GetClaims),\n\t rbac.NewCachedPermissionProvider(dbProvider, valkeyCache, 5*time.Minute),\n\t)\n\n\t// After provider AuthMiddleware (from auth-jwt or auth-firebase):\n\tsrv.Use(authmw.EnrichmentMiddleware(logger, enricher))\n\n\t// Per-route authorization:\n\tsrv.With(authmw.AuthzMiddleware(logger, permissions, \"orders\", security.Permission(0))).\n\t Get(\"/orders\", ordersHandler)\n\n# Multi-tenant\n\nPass the tenant identifier via a request header:\n\n\tsrv.Use(authmw.EnrichmentMiddleware(logger, enricher, authmw.WithTenantHeader(\"X-Tenant-ID\")))\n\n[authmw.WithTenantHeader] populates [security.Identity.TenantID] from the header.\n[rbac.NewCachedPermissionProvider] automatically scopes its cache keys by TenantID\nwhen non-empty — no additional configuration required.",
|
||
"goVersion": "1.26",
|
||
"dependsOn": [
|
||
"contracts",
|
||
"core",
|
||
"web"
|
||
],
|
||
"subPackages": [
|
||
{
|
||
"name": "",
|
||
"importPath": "code.nochebuena.dev/einherjar/auth",
|
||
"doc": "Package auth provides provider-agnostic HTTP authentication and authorization\nmiddleware for the Einherjar framework.\n\nauth absorbs two micro-lib packages:\n - httpauth → sub-package authmw (middleware + identity enrichment)\n - rbac → sub-package rbac (permission provider implementations)\n\nTypes that cross the full dependency graph (Identity, Permission, PermissionMask,\nPermissionProvider) live in contracts/security, not here. This module provides\nimplementations and middleware, not type definitions.\n\n# Sub-packages\n\n[authmw] — HTTP middleware layer. Three functions compose the full auth chain:\n\n - [authmw.SetTokenData] — integration contract called by provider packages\n (auth-jwt, auth-firebase) after token verification.\n - [authmw.EnrichmentMiddleware] — converts uid+claims into a security.Identity\n and stores it in context. The application provides the [authmw.IdentityEnricher]\n implementation that loads user data.\n - [authmw.AuthzMiddleware] — per-route permission gate. Takes a\n [security.PermissionProvider] and the required permission for the route.\n\n[rbac] — permission resolution. Three provider implementations satisfy\n[security.PermissionProvider]:\n\n - [rbac.NewClaimsPermissionProvider] — reads pre-computed bitmasks from JWT\n claims. Zero DB calls. Single-tenant fast-path.\n - [rbac.NewCachedPermissionProvider] — wraps any provider with a TTL cache.\n Cache keys are automatically scoped by TenantID when present.\n - [rbac.NewChainPermissionProvider] — tries providers in order; returns the\n first non-zero mask. Typical: claims fast-path → cached DB fallback.\n\n# Wiring Example\n\n\tenricher := userservice.NewIdentityEnricher(userRepo)\n\n\tpermissions := rbac.NewChainPermissionProvider(\n\t rbac.NewClaimsPermissionProvider(\"perms\", authmw.GetClaims),\n\t rbac.NewCachedPermissionProvider(dbProvider, valkeyCache, 5*time.Minute),\n\t)\n\n\t// After provider AuthMiddleware (from auth-jwt or auth-firebase):\n\tsrv.Use(authmw.EnrichmentMiddleware(logger, enricher))\n\n\t// Per-route authorization:\n\tsrv.With(authmw.AuthzMiddleware(logger, permissions, \"orders\", security.Permission(0))).\n\t Get(\"/orders\", ordersHandler)\n\n# Multi-tenant\n\nPass the tenant identifier via a request header:\n\n\tsrv.Use(authmw.EnrichmentMiddleware(logger, enricher, authmw.WithTenantHeader(\"X-Tenant-ID\")))\n\n[authmw.WithTenantHeader] populates [security.Identity.TenantID] from the header.\n[rbac.NewCachedPermissionProvider] automatically scopes its cache keys by TenantID\nwhen non-empty — no additional configuration required."
|
||
},
|
||
{
|
||
"name": "authmw",
|
||
"importPath": "code.nochebuena.dev/einherjar/auth/authmw",
|
||
"doc": "Package authmw provides provider-agnostic HTTP authentication and authorization\nmiddleware for the Einherjar framework.\n\nThe middleware chain follows a three-step pattern:\n\n 1. A provider-specific AuthMiddleware (from auth-jwt or auth-firebase) verifies\n the token and calls [SetTokenData] to store uid and claims in the request context.\n\n 2. [EnrichmentMiddleware] reads uid+claims, calls the application-provided\n [IdentityEnricher] to build a [security.Identity], wraps it in a\n [security.SecurityBag], runs any registered [BagEnricher] functions to attach\n extra attributes (tenant ID, hardware IDs, grant codes), and stores the bag\n in context via [security.SetBagInContext].\n\n 3. [AuthzMiddleware] — mounted per route — reads the identity from context and\n checks the required permission against a [security.PermissionProvider].\n\n# Typical wiring\n\n\tenricher := userservice.NewIdentityEnricher(userRepo)\n\n\t// Provider AuthMiddleware is added first (from auth-jwt or auth-firebase).\n\t// Then, globally:\n\tsrv.Use(authmw.EnrichmentMiddleware(logger, enricher))\n\n\t// Per route:\n\tconst Read = security.Permission(0)\n\tsrv.With(authmw.AuthzMiddleware(logger, permProvider, \"orders\", Read)).\n\t Get(\"/orders\", ordersHandler)\n\n# Multi-tenant\n\n\tsrv.Use(authmw.EnrichmentMiddleware(logger, enricher, authmw.WithTenantHeader(\"X-Tenant-ID\")))\n\n# Custom bag enrichment\n\n\tconst KeyHardwareID = \"hardware_id\"\n\n\thwEnricher := authmw.BagEnricher(func(bag security.SecurityBag, r *http.Request) security.SecurityBag {\n\t return bag.With(KeyHardwareID, r.Header.Get(\"X-Hardware-ID\"))\n\t})\n\n\tsrv.Use(authmw.EnrichmentMiddleware(logger, enricher, authmw.WithBagEnricher(hwEnricher)))"
|
||
},
|
||
{
|
||
"name": "rbac",
|
||
"importPath": "code.nochebuena.dev/einherjar/auth/rbac",
|
||
"doc": "Package rbac provides permission provider implementations for the Einherjar\nauthorization system. All constructors return [security.PermissionProvider]\nfrom contracts/security — no new types are defined here.\n\n# Permission providers\n\nThree strategies compose into a complete authorization pipeline:\n\n\t// Fast-path: reads pre-computed bitmasks from JWT claims in context.\n\t// Pass authmw.GetClaims so rbac does not import authmw directly.\n\tclaims := rbac.NewClaimsPermissionProvider(\"perms\", authmw.GetClaims)\n\n\t// DB + cache: wraps any provider with TTL caching. Cache key is\n\t// automatically scoped by TenantID for multi-tenant deployments.\n\tcached := rbac.NewCachedPermissionProvider(dbProvider, valkeyCache, 5*time.Minute)\n\n\t// Chain: tries claims first, falls through to DB on miss.\n\tchain := rbac.NewChainPermissionProvider(claims, cached)\n\n# Cache key customization\n\nWhen additional bag attributes must be part of the cache key (e.g. hardware IDs):\n\n\tconst KeyHardwareID = \"hardware_id\"\n\n\tcached := rbac.NewCachedPermissionProvider(dbProvider, cache, 5*time.Minute,\n\t rbac.WithCacheKey(func(bag security.SecurityBag, uid, resource string) string {\n\t hwID, _ := bag.Get(KeyHardwareID)\n\t return fmt.Sprintf(\"rbac:%s:%s:%v:%s\", bag.Identity().TenantID, uid, hwID, resource)\n\t }),\n\t)\n\n# Cache interface\n\n[Cache] is satisfied by einherjar/cache-valkey via Go duck typing.\nNo import of auth/rbac is needed by the cache implementation.\n\n# Multi-tenant\n\n[NewCachedPermissionProvider] automatically includes TenantID in the cache key\nwhen [security.Identity.TenantID] is non-empty in the request bag.\nPopulate TenantID via [authmw.WithTenantHeader] in EnrichmentMiddleware."
|
||
}
|
||
],
|
||
"symbols": [
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "moduleID",
|
||
"signature": "type moduleID struct",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 14
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "moduleID.ModulePath",
|
||
"signature": "func (m *moduleID) ModulePath() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 18
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "moduleID.ModuleVersion",
|
||
"signature": "func (m *moduleID) ModuleVersion() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 20
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "",
|
||
"kind": "const",
|
||
"name": "modulePath",
|
||
"signature": "const modulePath = \"code.nochebuena.dev/einherjar/auth\"",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 16
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "",
|
||
"kind": "var",
|
||
"name": "Module",
|
||
"signature": "var Module observability.Identifiable = \u0026moduleID{}",
|
||
"doc": "Module identifies this package to observability systems.\nauth is middleware-only — it is not registered with the launcher as a lifecycle\ncomponent. Register Module manually with any version registry if needed.",
|
||
"file": "identifiable.go",
|
||
"line": 12
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "authmw",
|
||
"kind": "type",
|
||
"name": "BagEnricher",
|
||
"signature": "type BagEnricher func(bag security.SecurityBag, r *http.Request) security.SecurityBag",
|
||
"doc": "BagEnricher enriches the request-scoped SecurityBag.\n\nCalled sequentially by [EnrichmentMiddleware] after the base Identity is\nbuilt from uid+claims. Each enricher receives the current bag and must\nreturn a new bag — the receiver is never modified.\n\nTypical uses: setting TenantID from a request header, attaching a hardware\nID to the bag, injecting a grant code from an out-of-band lookup.\n\nRegister enrichers via [WithBagEnricher]. [WithTenantHeader] is a\nconvenience constructor for the most common single-header case.",
|
||
"file": "authmw/bag_enricher.go",
|
||
"line": 20
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "authmw",
|
||
"kind": "type",
|
||
"name": "EnrichOpt",
|
||
"signature": "type EnrichOpt func(*enrichConfig)",
|
||
"doc": "EnrichOpt configures [EnrichmentMiddleware] behaviour.",
|
||
"file": "authmw/enrich_opt.go",
|
||
"line": 10
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "authmw",
|
||
"kind": "func",
|
||
"name": "WithBagEnricher",
|
||
"signature": "func WithBagEnricher(fn BagEnricher) EnrichOpt",
|
||
"doc": "WithBagEnricher appends fn to the enrichment chain.\nEnrichers run in registration order after the base Identity is built.\nEach enricher receives the bag returned by the previous one.\n\nUse this for any enrichment that does not fit [WithTenantHeader]:\nattaching hardware IDs, grant codes, or any attribute that downstream\npermission providers need to read from the bag.",
|
||
"file": "authmw/enrich_opt.go",
|
||
"line": 34
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "authmw",
|
||
"kind": "func",
|
||
"name": "WithTenantHeader",
|
||
"signature": "func WithTenantHeader(header string) EnrichOpt",
|
||
"doc": "WithTenantHeader reads the TenantID from the named request header and\napplies it to the Identity inside the bag via [security.Identity.WithTenant].\n\nEquivalent to registering a [BagEnricher] that calls\nbag.WithIdentity(bag.Identity().WithTenant(r.Header.Get(header))).\nUse for multi-tenant deployments where the tenant is identified per request.",
|
||
"file": "authmw/enrich_opt.go",
|
||
"line": 18
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "authmw",
|
||
"kind": "interface",
|
||
"name": "IdentityEnricher",
|
||
"signature": "type IdentityEnricher interface",
|
||
"doc": "IdentityEnricher is implemented by the application layer to load user data\nfrom token claims and return a populated security.Identity.\nCalled once per request by EnrichmentMiddleware after token verification.",
|
||
"file": "authmw/identity_enricher.go",
|
||
"line": 12,
|
||
"methods": [
|
||
{
|
||
"name": "Enrich",
|
||
"signature": "Enrich(ctx context.Context, uid string, claims map[string]any) (security.Identity, error)"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "authmw",
|
||
"kind": "type",
|
||
"name": "ctxClaimsKey",
|
||
"signature": "type ctxClaimsKey struct",
|
||
"doc": "",
|
||
"file": "authmw/token.go",
|
||
"line": 6
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "authmw",
|
||
"kind": "type",
|
||
"name": "ctxUIDKey",
|
||
"signature": "type ctxUIDKey struct",
|
||
"doc": "",
|
||
"file": "authmw/token.go",
|
||
"line": 5
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "authmw",
|
||
"kind": "type",
|
||
"name": "enrichConfig",
|
||
"signature": "type enrichConfig struct",
|
||
"doc": "",
|
||
"file": "authmw/enrichment.go",
|
||
"line": 12,
|
||
"fields": [
|
||
{
|
||
"name": "enrichers",
|
||
"type": "[]BagEnricher"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "authmw",
|
||
"kind": "func",
|
||
"name": "AuthzMiddleware",
|
||
"signature": "func AuthzMiddleware(logger logging.Logger, provider security.PermissionProvider, resource string, required security.Permission) func(http.Handler) http.Handler",
|
||
"doc": "AuthzMiddleware gates the request against a single required permission on a named resource.\nReturns 401 if no identity is in context; 403 if the permission check fails or the\nprovider returns an error (fail-closed: provider failure denies access).",
|
||
"file": "authmw/authz.go",
|
||
"line": 15
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "authmw",
|
||
"kind": "func",
|
||
"name": "EnrichmentMiddleware",
|
||
"signature": "func EnrichmentMiddleware(logger logging.Logger, enricher IdentityEnricher, opts ...EnrichOpt) func(http.Handler) http.Handler",
|
||
"doc": "EnrichmentMiddleware builds a [security.SecurityBag] from the uid and claims\ninjected by an upstream provider AuthMiddleware via [SetTokenData], then runs\nall registered [BagEnricher] functions in order.\n\nReturns 401 if no uid is present in context; 500 if the application\n[IdentityEnricher] returns an error.\n\nThe resulting bag is stored via [security.SetBagInContext]. Downstream\nhandlers can retrieve it with [security.BagFromContext] (full bag) or\n[security.FromContext] (Identity only).",
|
||
"file": "authmw/enrichment.go",
|
||
"line": 26
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "authmw",
|
||
"kind": "func",
|
||
"name": "GetClaims",
|
||
"signature": "func GetClaims(ctx context.Context) map[string]any",
|
||
"doc": "GetClaims returns the raw token claims stored by SetTokenData.\nReturns nil if SetTokenData was not called on this context.\nUseful for custom IdentityEnricher implementations and ClaimsPermissionProvider.",
|
||
"file": "authmw/token.go",
|
||
"line": 25
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "authmw",
|
||
"kind": "func",
|
||
"name": "SetTokenData",
|
||
"signature": "func SetTokenData(ctx context.Context, uid string, claims map[string]any) context.Context",
|
||
"doc": "SetTokenData stores uid and token claims in context.\nCalled by provider-specific AuthMiddleware (auth-jwt, auth-firebase) after\ntoken verification. EnrichmentMiddleware reads these values downstream.",
|
||
"file": "authmw/token.go",
|
||
"line": 11
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "authmw",
|
||
"kind": "func",
|
||
"name": "getClaims",
|
||
"signature": "func getClaims(ctx context.Context) map[string]any",
|
||
"doc": "",
|
||
"file": "authmw/token.go",
|
||
"line": 30
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "authmw",
|
||
"kind": "func",
|
||
"name": "getUID",
|
||
"signature": "func getUID(ctx context.Context) (string, bool)",
|
||
"doc": "",
|
||
"file": "authmw/token.go",
|
||
"line": 17
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "interface",
|
||
"name": "Cache",
|
||
"signature": "type Cache interface",
|
||
"doc": "Cache is the pluggable caching backend for [NewCachedPermissionProvider].\nSatisfied by einherjar/cache-valkey via Go duck typing — no import of auth/rbac needed.\n\nGet returns (value, true, nil) on hit; (0, false, nil) on miss; (0, false, err) on error.\nSet errors are silently ignored by the provider — cache is best-effort.",
|
||
"file": "rbac/cache.go",
|
||
"line": 13,
|
||
"methods": [
|
||
{
|
||
"name": "Get",
|
||
"signature": "Get(ctx context.Context, key string) (int64, bool, error)"
|
||
},
|
||
{
|
||
"name": "Set",
|
||
"signature": "Set(ctx context.Context, key string, value int64, ttl time.Duration) error"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "type",
|
||
"name": "CachedOpt",
|
||
"signature": "type CachedOpt func(*cachedConfig)",
|
||
"doc": "CachedOpt configures [NewCachedPermissionProvider] behaviour.",
|
||
"file": "rbac/cached_opt.go",
|
||
"line": 6
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "func",
|
||
"name": "WithCacheKey",
|
||
"signature": "func WithCacheKey(fn func(security.SecurityBag, string, string) string) CachedOpt",
|
||
"doc": "WithCacheKey overrides the default cache key function.\n\nfn receives the full [security.SecurityBag], uid, and resource name and\nreturns the cache key string. Use when the default key format\n(\"rbac:{uid}:{resource}\" or \"rbac:{tenantID}:{uid}:{resource}\") is\ninsufficient — for example when hardware IDs, grant codes, or other bag\nattributes must be part of the key to prevent cross-context cache pollution.\n\n\trbac.NewCachedPermissionProvider(inner, cache, ttl,\n\t rbac.WithCacheKey(func(bag security.SecurityBag, uid, resource string) string {\n\t hwID, _ := bag.Get(KeyHardwareID)\n\t return fmt.Sprintf(\"rbac:%s:%s:%v:%s\", bag.Identity().TenantID, uid, hwID, resource)\n\t }),\n\t)",
|
||
"file": "rbac/cached_opt.go",
|
||
"line": 22
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "type",
|
||
"name": "cachedConfig",
|
||
"signature": "type cachedConfig struct",
|
||
"doc": "",
|
||
"file": "rbac/cached_provider.go",
|
||
"line": 13,
|
||
"fields": [
|
||
{
|
||
"name": "keyFn",
|
||
"type": "func(security.SecurityBag, string, string) string"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "type",
|
||
"name": "cachedPermissionProvider",
|
||
"signature": "type cachedPermissionProvider struct",
|
||
"doc": "",
|
||
"file": "rbac/cached_provider.go",
|
||
"line": 17,
|
||
"fields": [
|
||
{
|
||
"name": "inner",
|
||
"type": "security.PermissionProvider"
|
||
},
|
||
{
|
||
"name": "cache",
|
||
"type": "Cache"
|
||
},
|
||
{
|
||
"name": "ttl",
|
||
"type": "time.Duration"
|
||
},
|
||
{
|
||
"name": "cfg",
|
||
"type": "cachedConfig"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "method",
|
||
"name": "cachedPermissionProvider.ResolveMask",
|
||
"signature": "func (p *cachedPermissionProvider) ResolveMask(ctx context.Context, uid, resource string) (security.PermissionMask, error)",
|
||
"doc": "",
|
||
"file": "rbac/cached_provider.go",
|
||
"line": 44
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "method",
|
||
"name": "cachedPermissionProvider.defaultKey",
|
||
"signature": "func (p *cachedPermissionProvider) defaultKey(ctx context.Context, uid, resource string) string",
|
||
"doc": "",
|
||
"file": "rbac/cached_provider.go",
|
||
"line": 66
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "type",
|
||
"name": "chainPermissionProvider",
|
||
"signature": "type chainPermissionProvider struct",
|
||
"doc": "",
|
||
"file": "rbac/chain_provider.go",
|
||
"line": 11,
|
||
"fields": [
|
||
{
|
||
"name": "providers",
|
||
"type": "[]security.PermissionProvider"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "method",
|
||
"name": "chainPermissionProvider.ResolveMask",
|
||
"signature": "func (c *chainPermissionProvider) ResolveMask(ctx context.Context, uid, resource string) (security.PermissionMask, error)",
|
||
"doc": "",
|
||
"file": "rbac/chain_provider.go",
|
||
"line": 28
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "type",
|
||
"name": "claimsPermissionProvider",
|
||
"signature": "type claimsPermissionProvider struct",
|
||
"doc": "",
|
||
"file": "rbac/claims_provider.go",
|
||
"line": 12,
|
||
"fields": [
|
||
{
|
||
"name": "claimsKey",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"name": "getClaims",
|
||
"type": "func(context.Context) map[string]any"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "method",
|
||
"name": "claimsPermissionProvider.ResolveMask",
|
||
"signature": "func (p *claimsPermissionProvider) ResolveMask(ctx context.Context, _, resource string) (security.PermissionMask, error)",
|
||
"doc": "",
|
||
"file": "rbac/claims_provider.go",
|
||
"line": 34
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "func",
|
||
"name": "NewCachedPermissionProvider",
|
||
"signature": "func NewCachedPermissionProvider(inner security.PermissionProvider, cache Cache, ttl time.Duration, opts ...CachedOpt) security.PermissionProvider",
|
||
"doc": "NewCachedPermissionProvider wraps any PermissionProvider with a TTL cache.\n\nDefault cache key format:\n - \"rbac:{uid}:{resource}\" — single-tenant (no TenantID in bag)\n - \"rbac:{tenantID}:{uid}:{resource}\" — multi-tenant (TenantID non-empty)\n\nTenantID is read automatically from the [security.SecurityBag] in context —\nno API change required. Use [WithCacheKey] to override the key function when\nadditional bag attributes (hardware IDs, grant codes) must be part of the key.\n\nCache errors are silently swallowed — falls through to the inner provider.\nSet errors are ignored (cache is best-effort).",
|
||
"file": "rbac/cached_provider.go",
|
||
"line": 36
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "func",
|
||
"name": "NewChainPermissionProvider",
|
||
"signature": "func NewChainPermissionProvider(providers ...security.PermissionProvider) security.PermissionProvider",
|
||
"doc": "NewChainPermissionProvider tries providers in order, returning the first non-zero mask.\nErrors short-circuit the chain immediately.\n\nTypical pattern: JWT claims fast-path → cached DB fallback:\n\n\trbac.NewChainPermissionProvider(\n\t rbac.NewClaimsPermissionProvider(\"perms\"),\n\t rbac.NewCachedPermissionProvider(dbProvider, cache, 5*time.Minute),\n\t)",
|
||
"file": "rbac/chain_provider.go",
|
||
"line": 24
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "func",
|
||
"name": "NewClaimsPermissionProvider",
|
||
"signature": "func NewClaimsPermissionProvider(claimsKey string, getClaims func(context.Context) map[string]any) security.PermissionProvider",
|
||
"doc": "NewClaimsPermissionProvider returns a PermissionProvider that reads\npre-computed permission bitmasks from JWT claims stored in context.\n\nclaimsKey is the top-level claim key whose value is a map[resource]int64.\nFalls back to wildcard key \"*\" if the specific resource is absent.\nZero DB calls — fastest permission resolution for read-heavy APIs.\n\ngetClaims is the function used to retrieve claims from context.\nPass authmw.GetClaims when using the authmw middleware chain.\n\nFlat claims format: claims[\"perms\"][\"orders\"] = 7\nFor multi-tenant permission isolation, use [NewCachedPermissionProvider]\nwrapping a DB lookup — it automatically scopes cache keys per tenant.",
|
||
"file": "rbac/claims_provider.go",
|
||
"line": 30
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "func",
|
||
"name": "extractMask",
|
||
"signature": "func extractMask(m map[string]any, key string) int64",
|
||
"doc": "",
|
||
"file": "rbac/claims_provider.go",
|
||
"line": 57
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "var",
|
||
"name": "_",
|
||
"signature": "var _ security.PermissionProvider = (*cachedPermissionProvider)(nil)",
|
||
"doc": "",
|
||
"file": "rbac/cached_provider.go",
|
||
"line": 11
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "var",
|
||
"name": "_",
|
||
"signature": "var _ security.PermissionProvider = (*chainPermissionProvider)(nil)",
|
||
"doc": "",
|
||
"file": "rbac/chain_provider.go",
|
||
"line": 9
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "rbac",
|
||
"kind": "var",
|
||
"name": "_",
|
||
"signature": "var _ security.PermissionProvider = (*claimsPermissionProvider)(nil)",
|
||
"doc": "",
|
||
"file": "rbac/claims_provider.go",
|
||
"line": 10
|
||
}
|
||
],
|
||
"adrs": null,
|
||
"examples": [
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "",
|
||
"title": "Dependency graph",
|
||
"code": "contracts/security ──► auth/authmw ──► auth/rbac\ncontracts/security ──► auth/rbac\ncore/xerrors ──► auth/authmw\nweb/httputil ──► auth/authmw",
|
||
"language": ""
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "",
|
||
"title": "Wiring example",
|
||
"code": "import (\n \"code.nochebuena.dev/einherjar/auth/authmw\"\n \"code.nochebuena.dev/einherjar/auth/rbac\"\n \"code.nochebuena.dev/einherjar/contracts/security\"\n)\n\n// Application implements IdentityEnricher to load user data.\nenricher := userservice.NewIdentityEnricher(userRepo)\n\n// Build permission resolution chain.\npermissions := rbac.NewChainPermissionProvider(\n rbac.NewClaimsPermissionProvider(\"perms\", authmw.GetClaims), // JWT fast-path\n rbac.NewCachedPermissionProvider(dbProvider, valkeyCache, 5*time.Minute), // DB fallback\n)\n\n// Provider AuthMiddleware (from auth-jwt or auth-firebase) goes first.\n// Then enrichment globally:\nsrv.Use(authmw.EnrichmentMiddleware(logger, enricher,\n authmw.WithTenantHeader(\"X-Tenant-ID\"),\n))\n\n// Per-route authorization:\nconst ReadOrders = security.Permission(0)\nsrv.With(authmw.AuthzMiddleware(logger, permissions, \"orders\", ReadOrders)).\n Get(\"/orders\", ordersHandler)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "",
|
||
"title": "Custom enrichment",
|
||
"code": "const KeyHardwareID = \"hardware_id\" // owned by your package; document the value type\n\nhwEnricher := authmw.BagEnricher(func(bag security.SecurityBag, r *http.Request) security.SecurityBag {\n return bag.With(KeyHardwareID, r.Header.Get(\"X-Hardware-ID\"))\n})\n\nsrv.Use(authmw.EnrichmentMiddleware(logger, enricher,\n authmw.WithTenantHeader(\"X-Tenant-ID\"),\n authmw.WithBagEnricher(hwEnricher),\n))",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "",
|
||
"title": "Custom enrichment",
|
||
"code": "cached := rbac.NewCachedPermissionProvider(dbProvider, cache, 5*time.Minute,\n rbac.WithCacheKey(func(bag security.SecurityBag, uid, resource string) string {\n hwID, _ := bag.Get(KeyHardwareID)\n return fmt.Sprintf(\"rbac:%s:%s:%v:%s\", bag.Identity().TenantID, uid, hwID, resource)\n }),\n)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "",
|
||
"title": "Multi-tenant",
|
||
"code": "// Read TenantID from header; CachedPermissionProvider scopes keys automatically.\nsrv.Use(authmw.EnrichmentMiddleware(logger, enricher, authmw.WithTenantHeader(\"X-Tenant-ID\")))",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "",
|
||
"title": "Permission model",
|
||
"code": "const (\n Read = security.Permission(0)\n Write = security.Permission(1)\n Delete = security.Permission(2)\n Admin = security.Permission(3)\n)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "",
|
||
"title": "Permission model",
|
||
"code": "customClaims := map[string]any{\n \"perms\": map[string]any{\n \"orders\": int64(security.PermissionMask(0).Grant(Read).Grant(Write)),\n },\n}",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"subPackage": "",
|
||
"title": "Install",
|
||
"code": "go get code.nochebuena.dev/einherjar/auth@v1.1.2",
|
||
"language": "bash"
|
||
}
|
||
],
|
||
"compliance": {
|
||
"interfaceAsserts": [
|
||
{
|
||
"module": "auth",
|
||
"interface": "authmw.IdentityEnricher",
|
||
"impl": "(*mockEnricher)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 26
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"interface": "security.PermissionProvider",
|
||
"impl": "rbac.NewClaimsPermissionProvider(\"x\", authmw.GetClaims)",
|
||
"file": "compliance_test.go",
|
||
"line": 27
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"interface": "security.PermissionProvider",
|
||
"impl": "rbac.NewCachedPermissionProvider(rbac.NewClaimsPermissionProvider(\"x\", authmw.GetClaims), \u0026mockCache{}, time.Minute)",
|
||
"file": "compliance_test.go",
|
||
"line": 28
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"interface": "security.PermissionProvider",
|
||
"impl": "rbac.NewChainPermissionProvider()",
|
||
"file": "compliance_test.go",
|
||
"line": 29
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"interface": "rbac.Cache",
|
||
"impl": "(*mockCache)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 30
|
||
}
|
||
],
|
||
"tests": [
|
||
{
|
||
"module": "auth",
|
||
"name": "TestAtMostOneExportedTypePerFile",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 34
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestSetTokenData",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 86
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestEnrichmentMiddlewareSuccess",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 102
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestEnrichmentMiddlewareMissingUID",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 121
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestEnrichmentMiddlewareEnricherError",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 139
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestEnrichmentMiddlewareTenantHeader",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 155
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestEnrichmentMiddlewareBagEnricher",
|
||
"doc": "TestEnrichmentMiddlewareBagEnricher verifies that WithBagEnricher attaches a\ncustom attribute to the SecurityBag stored in context.",
|
||
"file": "compliance_test.go",
|
||
"line": 185
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestEnrichmentMiddlewareBagInContext",
|
||
"doc": "TestEnrichmentMiddlewareBagInContext verifies that security.BagFromContext works\nafter enrichment, and that the bag carries the enriched Identity.",
|
||
"file": "compliance_test.go",
|
||
"line": 224
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestAuthzMiddlewareAllowed",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 256
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestAuthzMiddlewareMissingIdentity",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 274
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestAuthzMiddlewarePermissionDenied",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 289
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestAuthzMiddlewareProviderError",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 306
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestClaimsProviderHit",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 325
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestClaimsProviderWildcard",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 341
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestClaimsProviderMissing",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 357
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestClaimsProviderFloat64",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 370
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestCachedProviderHit",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 388
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestCachedProviderMiss",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 405
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestCachedProviderCacheError",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 425
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestCachedProviderTenantKey",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 439
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestWithCacheKey",
|
||
"doc": "TestWithCacheKey verifies that a custom CachedOpt key function is used instead\nof the default, enabling bag attributes (e.g. hardware ID) to be part of the key.",
|
||
"file": "compliance_test.go",
|
||
"line": 463
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestChainProviderFirstNonZero",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 490
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestChainProviderFallthrough",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 507
|
||
},
|
||
{
|
||
"module": "auth",
|
||
"name": "TestChainProviderError",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 521
|
||
}
|
||
]
|
||
},
|
||
"readme": "# einherjar/auth\n\n[](https://code.nochebuena.dev/einherjar/auth)\n[](LICENSE)\n[](https://go.dev)\n\n\u003e Not every warrior who knocks at the gate deserves to pass. The Valkyries choose.\n\nProvider-agnostic HTTP authentication and authorization middleware for the Einherjar framework.\n\n## Sub-packages\n\n| Package | Description |\n|---|---|\n| [`authmw`](authmw/) | HTTP middleware: `EnrichmentMiddleware`, `AuthzMiddleware`, `SetTokenData`, `BagEnricher` |\n| [`rbac`](rbac/) | Permission providers: `ClaimsPermissionProvider`, `CachedPermissionProvider`, `ChainPermissionProvider` |\n\n## Dependency graph\n\n```\ncontracts/security ──► auth/authmw ──► auth/rbac\ncontracts/security ──► auth/rbac\ncore/xerrors ──► auth/authmw\nweb/httputil ──► auth/authmw\n```\n\nNo external dependencies.\n\n## Wiring example\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/auth/authmw\"\n \"code.nochebuena.dev/einherjar/auth/rbac\"\n \"code.nochebuena.dev/einherjar/contracts/security\"\n)\n\n// Application implements IdentityEnricher to load user data.\nenricher := userservice.NewIdentityEnricher(userRepo)\n\n// Build permission resolution chain.\npermissions := rbac.NewChainPermissionProvider(\n rbac.NewClaimsPermissionProvider(\"perms\", authmw.GetClaims), // JWT fast-path\n rbac.NewCachedPermissionProvider(dbProvider, valkeyCache, 5*time.Minute), // DB fallback\n)\n\n// Provider AuthMiddleware (from auth-jwt or auth-firebase) goes first.\n// Then enrichment globally:\nsrv.Use(authmw.EnrichmentMiddleware(logger, enricher,\n authmw.WithTenantHeader(\"X-Tenant-ID\"),\n))\n\n// Per-route authorization:\nconst ReadOrders = security.Permission(0)\nsrv.With(authmw.AuthzMiddleware(logger, permissions, \"orders\", ReadOrders)).\n Get(\"/orders\", ordersHandler)\n```\n\n## Custom enrichment\n\n`BagEnricher` lets you attach any request attribute to the `SecurityBag` in context.\nPermission providers read it via `bag.Get(key)` — no scattered context keys.\n\n```go\nconst KeyHardwareID = \"hardware_id\" // owned by your package; document the value type\n\nhwEnricher := authmw.BagEnricher(func(bag security.SecurityBag, r *http.Request) security.SecurityBag {\n return bag.With(KeyHardwareID, r.Header.Get(\"X-Hardware-ID\"))\n})\n\nsrv.Use(authmw.EnrichmentMiddleware(logger, enricher,\n authmw.WithTenantHeader(\"X-Tenant-ID\"),\n authmw.WithBagEnricher(hwEnricher),\n))\n```\n\nWith a hardware-ID-bound permission model, override the cache key so the hardware ID\nis included — otherwise two hardware IDs for the same user share a cache entry:\n\n```go\ncached := rbac.NewCachedPermissionProvider(dbProvider, cache, 5*time.Minute,\n rbac.WithCacheKey(func(bag security.SecurityBag, uid, resource string) string {\n hwID, _ := bag.Get(KeyHardwareID)\n return fmt.Sprintf(\"rbac:%s:%s:%v:%s\", bag.Identity().TenantID, uid, hwID, resource)\n }),\n)\n```\n\n## Multi-tenant\n\n```go\n// Read TenantID from header; CachedPermissionProvider scopes keys automatically.\nsrv.Use(authmw.EnrichmentMiddleware(logger, enricher, authmw.WithTenantHeader(\"X-Tenant-ID\")))\n```\n\n- JWT carries \"who are you\" only — no per-tenant permission claims required\n- `WithTenantHeader` populates `security.Identity.TenantID` from the request\n- `CachedPermissionProvider` uses `\"rbac:{tenantID}:{uid}:{resource}\"` when TenantID is non-empty\n\n## Permission model\n\nPermissions are a 63-bit set (`security.Permission(0)` through `security.MaxPermission`).\nDefine application permissions as constants:\n\n```go\nconst (\n Read = security.Permission(0)\n Write = security.Permission(1)\n Delete = security.Permission(2)\n Admin = security.Permission(3)\n)\n```\n\nIssue tokens with embedded masks (via auth-jwt):\n\n```go\ncustomClaims := map[string]any{\n \"perms\": map[string]any{\n \"orders\": int64(security.PermissionMask(0).Grant(Read).Grant(Write)),\n },\n}\n```\n\n## Environment variables\n\nNone. Auth middleware is wired in code, not configured via environment.\n\n## Install\n\n```bash\ngo get code.nochebuena.dev/einherjar/auth@v1.1.2\n```\n",
|
||
"changelog": "# Changelog\n\n## v1.1.3 — 2026-08-08\n\nPatch — coordinated framework version alignment. Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`, \\`web\\`) to v1.1.3.\nNo code or API changes.## v1.1.2 — 2026-08-08\n\nPatch — documentation fix plus coordinated framework version alignment.\n\n### Fixed\n\n- README wiring example: `rbac.NewClaimsPermissionProvider(\"perms\", authmw.GetClaims)` (was\n missing the `getClaims` argument); install line updated to v1.1.2.\n\n### Changed\n\n- Bumped `contracts`, `core`, `web` to v1.1.2.\n\n## v1.1.1 — 2026-08-07\n\nPatch — coordinated framework version alignment. Bumped `contracts`, `core`, `web` to v1.1.1.\nNo code or API changes.\n\n## v1.1.0 — 2026-08-07\n\nCoordinated framework version alignment — released in lockstep at v1.1.0.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`, `web`) to v1.1.0 via `go get` + `go mod tidy`.\n No code or API changes.\n\n## v1.0.0\n\nInitial release.\n\n### `authmw`\n\n- `BagEnricher` type — `func(bag security.SecurityBag, r *http.Request) security.SecurityBag`;\n enriches the request-scoped SecurityBag after the base Identity is built. Register via\n `WithBagEnricher`. Multiple enrichers run in registration order, each receiving the bag\n returned by the previous one.\n- `SetTokenData` — integration contract for provider packages (auth-jwt, auth-firebase).\n Stores uid and raw claims in context via typed keys; consumed by `EnrichmentMiddleware`.\n- `GetClaims` — exported accessor for raw token claims stored by `SetTokenData`. Available\n to custom `IdentityEnricher` implementations and `ClaimsPermissionProvider`.\n- `EnrichmentMiddleware` — builds a `security.SecurityBag` from uid+claims. Calls the\n application `IdentityEnricher`, wraps the Identity in a SecurityBag, runs all registered\n `BagEnricher` functions in order, then stores the bag via `security.SetBagInContext`.\n Accepts `logging.Logger`; routes errors through `httputil.Error` (401 on missing token,\n 500 on enricher failure).\n- `AuthzMiddleware` — per-route permission gate. Returns 401 on missing identity, 403 on\n provider error or insufficient permissions (fail-closed).\n- `IdentityEnricher` interface — implemented by the application to load user data from uid+claims.\n- `EnrichOpt` type — `func(*enrichConfig)`.\n- `WithTenantHeader(header string) EnrichOpt` — reads Identity.TenantID from a named request\n header. Implemented as a `BagEnricher` internally.\n- `WithBagEnricher(fn BagEnricher) EnrichOpt` — registers a custom enricher. Use for any\n attribute beyond TenantID: hardware IDs, grant codes, etc.\n\n### `rbac`\n\n- `NewClaimsPermissionProvider` — reads pre-computed bitmasks from JWT claims in context.\n Flat format: `claims[claimsKey][resource] = mask`. Wildcard `\"*\"` fallback.\n Handles int64, float64, json.Number.\n- `NewCachedPermissionProvider` — wraps any `security.PermissionProvider` with TTL caching.\n Default cache key: `\"rbac:{uid}:{resource}\"` (single-tenant) or\n `\"rbac:{tenantID}:{uid}:{resource}\"` (multi-tenant). TenantID sourced from the SecurityBag\n in context automatically. Accepts `...CachedOpt` for customization.\n- `CachedOpt` type — `func(*cachedConfig)`.\n- `WithCacheKey(fn func(security.SecurityBag, string, string) string) CachedOpt` — overrides\n the default cache key function. Use when additional bag attributes (hardware IDs, grant codes)\n must be part of the key.\n- `NewChainPermissionProvider` — tries providers in order; returns first non-zero mask. Errors\n short-circuit.\n- `Cache` interface — pluggable cache backend. Satisfied by `einherjar/cache-valkey` via duck\n typing.\n"
|
||
},
|
||
{
|
||
"name": "auth-jwt",
|
||
"importPath": "code.nochebuena.dev/einherjar/auth-jwt",
|
||
"purpose": "A warrior's seal is recognized anywhere — but only if it cannot be forged.",
|
||
"doc": "Package authjwt provides JWT authentication middleware and token lifecycle\nmanagement for the Einherjar framework. It supports HMAC-SHA256 (HS256),\nRSA-SHA256 (RS256), and ECDSA (ES256/ES384/ES512).\n\n# Typical wiring\n\n\tsigner := authjwt.NewHMACSigner([]byte(os.Getenv(\"JWT_SECRET\")))\n\tcfg := authjwt.TokenConfig{\n\t AccessTTL: 15 * time.Minute,\n\t RefreshTTL: 7 * 24 * time.Hour,\n\t Issuer: \"myapp\",\n\t}\n\n\t// Verify Bearer tokens and inject uid+claims into context.\n\tsrv.Use(authjwt.AuthMiddleware(logger, signer, []string{\"/health\", \"/auth/*\"}))\n\n\t// Enrichment and authz from auth/authmw follow downstream.\n\tsrv.Use(authmw.EnrichmentMiddleware(logger, userEnricher))\n\n\t// Issue tokens on login:\n\tpair, err := authjwt.IssueTokenPair(signer, uid, customClaims, cfg)\n\n\t// Rotate tokens on refresh:\n\tnewPair, err := authjwt.RefreshTokenPair(ctx, signer, body.RefreshToken, blacklist, cfg, freshClaims)\n\tif errors.Is(err, authjwt.ErrTokenRevoked) {\n\t // replay attack — force re-login\n\t}",
|
||
"goVersion": "1.26",
|
||
"dependsOn": [
|
||
"auth",
|
||
"contracts",
|
||
"core",
|
||
"web"
|
||
],
|
||
"subPackages": [
|
||
{
|
||
"name": "",
|
||
"importPath": "code.nochebuena.dev/einherjar/auth-jwt",
|
||
"doc": "Package authjwt provides JWT authentication middleware and token lifecycle\nmanagement for the Einherjar framework. It supports HMAC-SHA256 (HS256),\nRSA-SHA256 (RS256), and ECDSA (ES256/ES384/ES512).\n\n# Typical wiring\n\n\tsigner := authjwt.NewHMACSigner([]byte(os.Getenv(\"JWT_SECRET\")))\n\tcfg := authjwt.TokenConfig{\n\t AccessTTL: 15 * time.Minute,\n\t RefreshTTL: 7 * 24 * time.Hour,\n\t Issuer: \"myapp\",\n\t}\n\n\t// Verify Bearer tokens and inject uid+claims into context.\n\tsrv.Use(authjwt.AuthMiddleware(logger, signer, []string{\"/health\", \"/auth/*\"}))\n\n\t// Enrichment and authz from auth/authmw follow downstream.\n\tsrv.Use(authmw.EnrichmentMiddleware(logger, userEnricher))\n\n\t// Issue tokens on login:\n\tpair, err := authjwt.IssueTokenPair(signer, uid, customClaims, cfg)\n\n\t// Rotate tokens on refresh:\n\tnewPair, err := authjwt.RefreshTokenPair(ctx, signer, body.RefreshToken, blacklist, cfg, freshClaims)\n\tif errors.Is(err, authjwt.ErrTokenRevoked) {\n\t // replay attack — force re-login\n\t}"
|
||
}
|
||
],
|
||
"symbols": [
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Blacklist",
|
||
"signature": "type Blacklist interface",
|
||
"doc": "Blacklist records and checks revoked refresh token JTIs.\nSatisfied by einherjar/cache-valkey via duck typing.\nTTL on Revoke should match the token's remaining lifetime so entries expire naturally.",
|
||
"file": "blacklist.go",
|
||
"line": 17,
|
||
"methods": [
|
||
{
|
||
"name": "IsRevoked",
|
||
"signature": "IsRevoked(ctx context.Context, jti string) (bool, error)"
|
||
},
|
||
{
|
||
"name": "Revoke",
|
||
"signature": "Revoke(ctx context.Context, jti string, ttl time.Duration) error"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Signer",
|
||
"signature": "type Signer interface",
|
||
"doc": "Signer signs and verifies JWTs.\nNewHMACSigner, NewRSASigner, and NewECSigner return implementations backed by\nHS256, RS256, and ES256/ES384/ES512 respectively.",
|
||
"file": "signer.go",
|
||
"line": 8,
|
||
"methods": [
|
||
{
|
||
"signature": "Verifier"
|
||
},
|
||
{
|
||
"name": "Sign",
|
||
"signature": "Sign(claims jwt.Claims) (string, error)"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewECSigner",
|
||
"signature": "func NewECSigner(privateKey *ecdsa.PrivateKey) Signer",
|
||
"doc": "NewECSigner returns a Signer backed by ECDSA.\nThe signing algorithm is auto-detected from the key's curve:\nP-256→ES256, P-384→ES384, P-521→ES512.",
|
||
"file": "signer_ec.go",
|
||
"line": 22
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewECSignerFromPEM",
|
||
"signature": "func NewECSignerFromPEM(pemKey []byte) (Signer, error)",
|
||
"doc": "NewECSignerFromPEM parses a PKCS#8 PEM-encoded ECDSA private key.",
|
||
"file": "signer_ec.go",
|
||
"line": 27
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewHMACSigner",
|
||
"signature": "func NewHMACSigner(secret []byte) Signer",
|
||
"doc": "NewHMACSigner returns a Signer backed by HMAC-SHA256 (HS256).\nsecret should be at least 32 bytes; shorter values are accepted but weakened.",
|
||
"file": "signer_hmac.go",
|
||
"line": 15
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewRSASigner",
|
||
"signature": "func NewRSASigner(privateKey *rsa.PrivateKey) Signer",
|
||
"doc": "NewRSASigner returns a Signer backed by RSA-SHA256 (RS256).\nThe public key is derived from the private key — no separate argument needed.",
|
||
"file": "signer_rsa.go",
|
||
"line": 21
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewRSASignerFromPEM",
|
||
"signature": "func NewRSASignerFromPEM(pemKey []byte) (Signer, error)",
|
||
"doc": "NewRSASignerFromPEM parses a PKCS#8 or PKCS#1 PEM-encoded RSA private key.",
|
||
"file": "signer_rsa.go",
|
||
"line": 26
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "TokenConfig",
|
||
"signature": "type TokenConfig struct",
|
||
"doc": "TokenConfig configures token lifetimes and the issuer claim.",
|
||
"file": "token_config.go",
|
||
"line": 6,
|
||
"fields": [
|
||
{
|
||
"name": "AccessTTL",
|
||
"type": "time.Duration"
|
||
},
|
||
{
|
||
"name": "RefreshTTL",
|
||
"type": "time.Duration"
|
||
},
|
||
{
|
||
"name": "Issuer",
|
||
"type": "string"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "TokenPair",
|
||
"signature": "type TokenPair struct",
|
||
"doc": "TokenPair holds an access token and a refresh token.",
|
||
"file": "token_pair.go",
|
||
"line": 4,
|
||
"fields": [
|
||
{
|
||
"name": "AccessToken",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"name": "RefreshToken",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"name": "ExpiresIn",
|
||
"type": "int64",
|
||
"doc": "seconds until the access token expires"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "IssueTokenPair",
|
||
"signature": "func IssueTokenPair(signer Signer, uid string, customClaims map[string]any, cfg TokenConfig) (TokenPair, error)",
|
||
"doc": "IssueTokenPair signs a new access + refresh token pair for uid.\ncustomClaims are merged into the access token at the top level. Use this to embed\nper-resource permission masks so ClaimsPermissionProvider can read them without a DB call.\nThe refresh token carries only sub, iss, iat, exp, jti, and fam (token family).",
|
||
"file": "tokens.go",
|
||
"line": 15
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "RefreshTokenPair",
|
||
"signature": "func RefreshTokenPair(ctx context.Context, signer Signer, refreshToken string, bl Blacklist, cfg TokenConfig, customClaims map[string]any) (TokenPair, error)",
|
||
"doc": "RefreshTokenPair validates refreshToken, checks the blacklist, revokes the old JTI,\nand issues a new token pair for the same uid.\ncustomClaims are merged into the new access token — re-fetch fresh permissions here\nso role changes take effect without revoking outstanding access tokens.\nReturns ErrTokenRevoked if the JTI is already on the blacklist (replay attack or\nre-use after rotation).",
|
||
"file": "refresh.go",
|
||
"line": 17
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Verifier",
|
||
"signature": "type Verifier interface",
|
||
"doc": "Verifier validates JWT strings.\nServices that verify tokens but never issue them use a Verifier\n(e.g. NewRSAPublicKeyVerifier, NewECPublicKeyVerifier) instead of the full Signer.",
|
||
"file": "verifier.go",
|
||
"line": 8,
|
||
"methods": [
|
||
{
|
||
"name": "Verify",
|
||
"signature": "Verify(tokenString string) (*jwt.Token, error)"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewECPublicKeyVerifier",
|
||
"signature": "func NewECPublicKeyVerifier(publicKey *ecdsa.PublicKey) Verifier",
|
||
"doc": "NewECPublicKeyVerifier returns a Verifier backed by an ECDSA public key.\nUse this in services that verify tokens but never issue them.",
|
||
"file": "verifier_ec.go",
|
||
"line": 18
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewECPublicKeyVerifierFromPEM",
|
||
"signature": "func NewECPublicKeyVerifierFromPEM(pemKey []byte) (Verifier, error)",
|
||
"doc": "NewECPublicKeyVerifierFromPEM parses a PKIX PEM-encoded ECDSA public key.",
|
||
"file": "verifier_ec.go",
|
||
"line": 23
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewRSAPublicKeyVerifier",
|
||
"signature": "func NewRSAPublicKeyVerifier(publicKey *rsa.PublicKey) Verifier",
|
||
"doc": "NewRSAPublicKeyVerifier returns a Verifier backed by an RSA public key.\nUse this in services that verify tokens but never issue them.",
|
||
"file": "verifier_rsa.go",
|
||
"line": 18
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewRSAPublicKeyVerifierFromPEM",
|
||
"signature": "func NewRSAPublicKeyVerifierFromPEM(pemKey []byte) (Verifier, error)",
|
||
"doc": "NewRSAPublicKeyVerifierFromPEM parses a PKIX or PKCS#1 PEM-encoded RSA public key.",
|
||
"file": "verifier_rsa.go",
|
||
"line": 23
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "ecPublicVerifier",
|
||
"signature": "type ecPublicVerifier struct",
|
||
"doc": "",
|
||
"file": "verifier_ec.go",
|
||
"line": 14,
|
||
"fields": [
|
||
{
|
||
"name": "public",
|
||
"type": "*ecdsa.PublicKey"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "ecPublicVerifier.Verify",
|
||
"signature": "func (v *ecPublicVerifier) Verify(tokenString string) (*jwt.Token, error)",
|
||
"doc": "",
|
||
"file": "verifier_ec.go",
|
||
"line": 39
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "ecSigner",
|
||
"signature": "type ecSigner struct",
|
||
"doc": "",
|
||
"file": "signer_ec.go",
|
||
"line": 14,
|
||
"fields": [
|
||
{
|
||
"name": "private",
|
||
"type": "*ecdsa.PrivateKey"
|
||
},
|
||
{
|
||
"name": "public",
|
||
"type": "*ecdsa.PublicKey"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "ecSigner.Sign",
|
||
"signature": "func (s *ecSigner) Sign(claims jwt.Claims) (string, error)",
|
||
"doc": "",
|
||
"file": "signer_ec.go",
|
||
"line": 43
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "ecSigner.Verify",
|
||
"signature": "func (s *ecSigner) Verify(tokenString string) (*jwt.Token, error)",
|
||
"doc": "",
|
||
"file": "signer_ec.go",
|
||
"line": 47
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "hmacSigner",
|
||
"signature": "type hmacSigner struct",
|
||
"doc": "",
|
||
"file": "signer_hmac.go",
|
||
"line": 11,
|
||
"fields": [
|
||
{
|
||
"name": "secret",
|
||
"type": "[]byte"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "hmacSigner.Sign",
|
||
"signature": "func (s *hmacSigner) Sign(claims jwt.Claims) (string, error)",
|
||
"doc": "",
|
||
"file": "signer_hmac.go",
|
||
"line": 19
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "hmacSigner.Verify",
|
||
"signature": "func (s *hmacSigner) Verify(tokenString string) (*jwt.Token, error)",
|
||
"doc": "",
|
||
"file": "signer_hmac.go",
|
||
"line": 23
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "moduleID",
|
||
"signature": "type moduleID struct",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 14
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "moduleID.ModulePath",
|
||
"signature": "func (m *moduleID) ModulePath() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 18
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "moduleID.ModuleVersion",
|
||
"signature": "func (m *moduleID) ModuleVersion() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 20
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "rsaPublicVerifier",
|
||
"signature": "type rsaPublicVerifier struct",
|
||
"doc": "",
|
||
"file": "verifier_rsa.go",
|
||
"line": 14,
|
||
"fields": [
|
||
{
|
||
"name": "public",
|
||
"type": "*rsa.PublicKey"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "rsaPublicVerifier.Verify",
|
||
"signature": "func (v *rsaPublicVerifier) Verify(tokenString string) (*jwt.Token, error)",
|
||
"doc": "",
|
||
"file": "verifier_rsa.go",
|
||
"line": 43
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "rsaSigner",
|
||
"signature": "type rsaSigner struct",
|
||
"doc": "",
|
||
"file": "signer_rsa.go",
|
||
"line": 14,
|
||
"fields": [
|
||
{
|
||
"name": "private",
|
||
"type": "*rsa.PrivateKey"
|
||
},
|
||
{
|
||
"name": "public",
|
||
"type": "*rsa.PublicKey"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "rsaSigner.Sign",
|
||
"signature": "func (s *rsaSigner) Sign(claims jwt.Claims) (string, error)",
|
||
"doc": "",
|
||
"file": "signer_rsa.go",
|
||
"line": 46
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "rsaSigner.Verify",
|
||
"signature": "func (s *rsaSigner) Verify(tokenString string) (*jwt.Token, error)",
|
||
"doc": "",
|
||
"file": "signer_rsa.go",
|
||
"line": 50
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "AuthMiddleware",
|
||
"signature": "func AuthMiddleware(logger logging.Logger, verifier Verifier, publicPaths []string) func(http.Handler) http.Handler",
|
||
"doc": "AuthMiddleware verifies the Bearer access token and injects uid + claims into context\nvia authmw.SetTokenData. Downstream authmw.EnrichmentMiddleware reads them transparently.\n\nAccepts a Verifier — pass a Signer when the service issues tokens, or a\nNewRSAPublicKeyVerifier/NewECPublicKeyVerifier when it only verifies.\n\nRequests to publicPaths are skipped without verification (path.Match wildcards supported).\nReturns 401 on missing, invalid, or expired tokens.",
|
||
"file": "auth.go",
|
||
"line": 24
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "ecAlg",
|
||
"signature": "func ecAlg(key *ecdsa.PrivateKey) *jwt.SigningMethodECDSA",
|
||
"doc": "",
|
||
"file": "signer_ec.go",
|
||
"line": 57
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "newJTI",
|
||
"signature": "func newJTI() string",
|
||
"doc": "",
|
||
"file": "tokens.go",
|
||
"line": 55
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "writeUnauthorized",
|
||
"signature": "func writeUnauthorized(logger logging.Logger, w http.ResponseWriter, r *http.Request)",
|
||
"doc": "",
|
||
"file": "auth.go",
|
||
"line": 65
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "const",
|
||
"name": "modulePath",
|
||
"signature": "const modulePath = \"code.nochebuena.dev/einherjar/auth-jwt\"",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 16
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "var",
|
||
"name": "ErrTokenRevoked",
|
||
"signature": "var ErrTokenRevoked = errors.New(\"token revoked\")",
|
||
"doc": "ErrTokenRevoked is returned by RefreshTokenPair when the JTI is on the blacklist.\nUse errors.Is(err, authjwt.ErrTokenRevoked) to distinguish replay attacks from\ninfrastructure errors.",
|
||
"file": "blacklist.go",
|
||
"line": 12
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"kind": "var",
|
||
"name": "Module",
|
||
"signature": "var Module observability.Identifiable = \u0026moduleID{}",
|
||
"doc": "Module identifies this package to observability systems.\nauth-jwt is a function library — it is not registered with the launcher as a\nlifecycle component. Register Module manually with any version registry if needed.",
|
||
"file": "identifiable.go",
|
||
"line": 12
|
||
}
|
||
],
|
||
"adrs": null,
|
||
"examples": [
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"title": "Dependency graph",
|
||
"code": "contracts/logging ──► auth-jwt\ncontracts/security ──► auth-jwt (via auth/authmw)\ncore/xerrors ──► auth-jwt\nweb/httputil ──► auth-jwt\nauth/authmw ──► auth-jwt\njwt/v5 ──► auth-jwt (only external dependency)",
|
||
"language": ""
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"title": "Wiring example — HMAC, full stack",
|
||
"code": "import (\n \"code.nochebuena.dev/einherjar/auth-jwt\"\n \"code.nochebuena.dev/einherjar/auth/authmw\"\n \"code.nochebuena.dev/einherjar/auth/rbac\"\n)\n\nsigner := authjwt.NewHMACSigner([]byte(os.Getenv(\"JWT_SECRET\")))\ncfg := authjwt.TokenConfig{\n AccessTTL: 15 * time.Minute,\n RefreshTTL: 7 * 24 * time.Hour,\n Issuer: \"myapp\",\n}\n\n// JWT verification runs first (global).\nsrv.Use(authjwt.AuthMiddleware(logger, signer, []string{\"/health\", \"/auth/*\"}))\n\n// Enrichment and authz follow.\nsrv.Use(authmw.EnrichmentMiddleware(logger, userEnricher))\n\nconst ReadOrders = security.Permission(0)\nsrv.With(authmw.AuthzMiddleware(logger, permissions, \"orders\", ReadOrders)).\n Get(\"/orders\", ordersHandler)\n\n// Login handler issues tokens:\npair, err := authjwt.IssueTokenPair(signer, uid, customClaims, cfg)\n\n// Refresh handler rotates tokens:\nnewPair, err := authjwt.RefreshTokenPair(ctx, signer, body.RefreshToken, blacklist, cfg, freshClaims)\nif errors.Is(err, authjwt.ErrTokenRevoked) {\n // replay attack — return 401 and require re-login\n}",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"title": "Verifier-only microservice (RSA)",
|
||
"code": "// Service that verifies tokens but never issues them.\nverifier, err := authjwt.NewRSAPublicKeyVerifierFromPEM([]byte(os.Getenv(\"RSA_PUBLIC_KEY_PEM\")))\nsrv.Use(authjwt.AuthMiddleware(logger, verifier, publicPaths))",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"subPackage": "",
|
||
"title": "Install",
|
||
"code": "go get code.nochebuena.dev/einherjar/auth-jwt@v1.1.2",
|
||
"language": "bash"
|
||
}
|
||
],
|
||
"compliance": {
|
||
"interfaceAsserts": [
|
||
{
|
||
"module": "auth-jwt",
|
||
"interface": "authjwt.Signer",
|
||
"impl": "(*mockSigner)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 98
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"interface": "authjwt.Verifier",
|
||
"impl": "(*mockVerifier)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 99
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"interface": "authjwt.Blacklist",
|
||
"impl": "(*mockBlacklist)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 100
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"interface": "logging.Logger",
|
||
"impl": "nopLogger{}",
|
||
"file": "compliance_test.go",
|
||
"line": 113
|
||
}
|
||
],
|
||
"tests": [
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestAtMostOneExportedTypePerFile",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 117
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestHMACSigner_SignAndVerify",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 153
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestHMACSigner_TamperedToken",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 169
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestHMACSigner_WrongSecret",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 178
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestHMACSigner_AlgMismatch",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 188
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestRSASigner_SignAndVerify",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 197
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestRSAPublicKeyVerifier_VerifiesTokenFromSigner",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 213
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestRSAPublicKeyVerifier_RejectsHMACToken",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 223
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestECSigner_SignAndVerify",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 233
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestECSigner_P384",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 249
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestECPublicKeyVerifier_VerifiesTokenFromSigner",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 263
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestECPublicKeyVerifier_RejectsHMACToken",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 273
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestIssueTokenPair_StandardClaims",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 285
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestIssueTokenPair_CustomClaims",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 312
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestIssueTokenPair_UniqueJTIs",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 333
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestIssueTokenPair_RefreshHasFam",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 344
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestRefreshTokenPair_Success",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 355
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestRefreshTokenPair_OldTokenRevoked",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 370
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestRefreshTokenPair_InvalidToken",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 382
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestRefreshTokenPair_BlacklistCheckError",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 390
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestRefreshTokenPair_CustomClaimsInNewToken",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 399
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestVerify_JSONNumberPreservesMaxInt64",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 424
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestAuthMiddleware_ValidToken",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 454
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestAuthMiddleware_InvalidToken",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 473
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestAuthMiddleware_ExpiredToken",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 486
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestAuthMiddleware_MissingHeader",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 501
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestAuthMiddleware_PublicPath",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 512
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestAuthMiddleware_PublicPathWildcard",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 523
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestAuthMiddleware_UnauthorizedJSON",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 534
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestAuthMiddleware_RSAPublicKeyVerifier",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 555
|
||
},
|
||
{
|
||
"module": "auth-jwt",
|
||
"name": "TestAuthMiddleware_SetsTokenData",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 575
|
||
}
|
||
]
|
||
},
|
||
"readme": "# einherjar/auth-jwt\n\n[](https://code.nochebuena.dev/einherjar/auth-jwt)\n[](LICENSE)\n[](https://go.dev)\n\n\u003e A warrior's seal is recognized anywhere — but only if it cannot be forged.\n\nJWT authentication middleware and token lifecycle management for the Einherjar framework.\nSupports HMAC-SHA256 (HS256), RSA-SHA256 (RS256), and ECDSA (ES256/ES384/ES512).\n\n## API\n\n| Symbol | Kind | Description |\n|---|---|---|\n| `Verifier` | interface | Validates JWT strings |\n| `Signer` | interface | Extends `Verifier`; also signs tokens |\n| `NewHMACSigner(secret)` | func | HS256 signer |\n| `NewRSASigner(key)` | func | RS256 signer |\n| `NewRSASignerFromPEM(pem)` | func | RS256 signer from PKCS#8/PKCS#1 PEM |\n| `NewRSAPublicKeyVerifier(key)` | func | RS256 verifier (verify-only) |\n| `NewRSAPublicKeyVerifierFromPEM(pem)` | func | RS256 verifier from PKIX/PKCS#1 PEM |\n| `NewECSigner(key)` | func | ES256/384/512 signer (curve auto-detected) |\n| `NewECSignerFromPEM(pem)` | func | EC signer from PKCS#8 PEM |\n| `NewECPublicKeyVerifier(key)` | func | EC verifier (verify-only) |\n| `NewECPublicKeyVerifierFromPEM(pem)` | func | EC verifier from PKIX PEM |\n| `TokenConfig` | struct | AccessTTL, RefreshTTL, Issuer |\n| `TokenPair` | struct | AccessToken, RefreshToken, ExpiresIn |\n| `IssueTokenPair(signer, uid, claims, cfg)` | func | Sign access + refresh pair |\n| `Blacklist` | interface | JTI revocation store (duck-typed by cache-valkey) |\n| `ErrTokenRevoked` | var | Sentinel for replay-attack detection |\n| `RefreshTokenPair(ctx, signer, token, bl, cfg, claims)` | func | Rotate tokens with blacklist check |\n| `AuthMiddleware(logger, verifier, publicPaths)` | func | HTTP middleware — verifies Bearer token, calls `authmw.SetTokenData` |\n\n## Dependency graph\n\n```\ncontracts/logging ──► auth-jwt\ncontracts/security ──► auth-jwt (via auth/authmw)\ncore/xerrors ──► auth-jwt\nweb/httputil ──► auth-jwt\nauth/authmw ──► auth-jwt\njwt/v5 ──► auth-jwt (only external dependency)\n```\n\n## Wiring example — HMAC, full stack\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/auth-jwt\"\n \"code.nochebuena.dev/einherjar/auth/authmw\"\n \"code.nochebuena.dev/einherjar/auth/rbac\"\n)\n\nsigner := authjwt.NewHMACSigner([]byte(os.Getenv(\"JWT_SECRET\")))\ncfg := authjwt.TokenConfig{\n AccessTTL: 15 * time.Minute,\n RefreshTTL: 7 * 24 * time.Hour,\n Issuer: \"myapp\",\n}\n\n// JWT verification runs first (global).\nsrv.Use(authjwt.AuthMiddleware(logger, signer, []string{\"/health\", \"/auth/*\"}))\n\n// Enrichment and authz follow.\nsrv.Use(authmw.EnrichmentMiddleware(logger, userEnricher))\n\nconst ReadOrders = security.Permission(0)\nsrv.With(authmw.AuthzMiddleware(logger, permissions, \"orders\", ReadOrders)).\n Get(\"/orders\", ordersHandler)\n\n// Login handler issues tokens:\npair, err := authjwt.IssueTokenPair(signer, uid, customClaims, cfg)\n\n// Refresh handler rotates tokens:\nnewPair, err := authjwt.RefreshTokenPair(ctx, signer, body.RefreshToken, blacklist, cfg, freshClaims)\nif errors.Is(err, authjwt.ErrTokenRevoked) {\n // replay attack — return 401 and require re-login\n}\n```\n\n## Verifier-only microservice (RSA)\n\n```go\n// Service that verifies tokens but never issues them.\nverifier, err := authjwt.NewRSAPublicKeyVerifierFromPEM([]byte(os.Getenv(\"RSA_PUBLIC_KEY_PEM\")))\nsrv.Use(authjwt.AuthMiddleware(logger, verifier, publicPaths))\n```\n\n## Environment variables\n\nNone. All configuration is passed in code.\n\n## Install\n\n```bash\ngo get code.nochebuena.dev/einherjar/auth-jwt@v1.1.2\n```\n",
|
||
"changelog": "# Changelog\n\n## v1.1.3 — 2026-08-08\n\nPatch — coordinated framework version alignment. Bumped einherjar dependencies (\\`auth\\`, \\`contracts\\`, \\`core\\`, \\`web\\`) to v1.1.3.\nNo code or API changes.## v1.1.2 — 2026-08-08\n\nPatch — coordinated framework version alignment. Bumped `auth`, `contracts`, `core`, `web` to\nv1.1.2; README install line updated to v1.1.2.\n\n## v1.1.1 — 2026-08-07\n\nPatch — coordinated framework version alignment. Bumped `auth`, `contracts`, `core`, `web` to v1.1.1.\nNo code or API changes.\n\n## v1.1.0 — 2026-08-07\n\nCoordinated framework version alignment — released in lockstep at v1.1.0.\n\n### Changed\n\n- Bumped einherjar dependencies (`auth`, `contracts`, `core`, `web`) to v1.1.0 via `go get` + `go mod tidy`.\n No code or API changes.\n\n## v1.0.0\n\nInitial release.\n\n### Signers and Verifiers\n\n- `Verifier` interface — `Verify(tokenString string) (*jwt.Token, error)`\n- `Signer` interface — extends `Verifier`; adds `Sign(claims jwt.Claims) (string, error)`\n- `NewHMACSigner(secret []byte) Signer` — HMAC-SHA256 (HS256)\n- `NewRSASigner(privateKey *rsa.PrivateKey) Signer` — RSA-SHA256 (RS256); public key derived from private\n- `NewRSASignerFromPEM(pemKey []byte) (Signer, error)` — parses PKCS#8 or PKCS#1 PEM\n- `NewRSAPublicKeyVerifier(publicKey *rsa.PublicKey) Verifier` — verify-only; use when the service never issues tokens\n- `NewRSAPublicKeyVerifierFromPEM(pemKey []byte) (Verifier, error)` — parses PKIX or PKCS#1 PEM\n- `NewECSigner(privateKey *ecdsa.PrivateKey) Signer` — ECDSA; algorithm auto-detected from curve (P-256→ES256, P-384→ES384, P-521→ES512)\n- `NewECSignerFromPEM(pemKey []byte) (Signer, error)` — parses PKCS#8 PEM\n- `NewECPublicKeyVerifier(publicKey *ecdsa.PublicKey) Verifier` — EC verify-only\n- `NewECPublicKeyVerifierFromPEM(pemKey []byte) (Verifier, error)` — parses PKIX PEM\n- All `Verify` implementations use `jwt.WithJSONNumber()` — preserves large int64 bitmasks through JSON round-trip\n\n### Token issuance\n\n- `TokenConfig` struct — `AccessTTL time.Duration`, `RefreshTTL time.Duration`, `Issuer string`\n- `TokenPair` struct — `AccessToken string`, `RefreshToken string`, `ExpiresIn int64`\n- `IssueTokenPair(signer, uid, customClaims, cfg) (TokenPair, error)` — signs access + refresh pair; `customClaims` merged at top level of access token; refresh token carries only `sub/iss/iat/exp/jti/fam`\n\n### Token refresh\n\n- `Blacklist` interface — `IsRevoked(ctx, jti) (bool, error)` + `Revoke(ctx, jti, ttl) error`; satisfied by `cache-valkey` via duck typing\n- `ErrTokenRevoked` — sentinel error; use `errors.Is(err, authjwt.ErrTokenRevoked)` to detect replay attacks\n- `RefreshTokenPair(ctx, signer, refreshToken, bl, cfg, customClaims) (TokenPair, error)` — validates token, checks blacklist, revokes old JTI, issues new pair; `customClaims` re-embedded in new access token\n\n### HTTP middleware\n\n- `AuthMiddleware(logger, verifier, publicPaths) func(http.Handler) http.Handler` — verifies Bearer tokens; calls `authmw.SetTokenData` on success; routes 401 through `httputil.Error`; `publicPaths` support `path.Match` wildcards\n"
|
||
},
|
||
{
|
||
"name": "cache-valkey",
|
||
"importPath": "code.nochebuena.dev/einherjar/cache-valkey",
|
||
"purpose": "Speed is not a virtue. It is the difference between arriving and never arriving.",
|
||
"doc": "Package cachevalkey provides a lifecycle-aware Valkey client with health checks\nand three adapters that integrate with other Einherjar starters via duck typing.\n\n# Quick start\n\n\tvk := cachevalkey.New(logger, cfg)\n\tlc.Append(vk) // lifecycle: OnInit → OnStart → OnStop\n\t// vk satisfies observability.Checkable (PING-based, LevelDegraded) — wire a health endpoint:\n\tsrv.Get(\"/health\", health.NewHandler(logger, vk).ServeHTTP)\n\n# Adapters\n\nEach adapter wraps the Provider and satisfies one interface in another module.\nGo's structural typing handles the assignment — no cast required:\n\n\tpermCache := cachevalkey.NewPermissionCache(vk) // → auth/rbac.Cache\n\trateLimiter := cachevalkey.NewRateLimiterStore(vk, time.Second, 100) // → web/mw.RateLimiterStore\n\tblacklist := cachevalkey.NewBlacklist(vk) // → auth-jwt.Blacklist\n\n# Configuration\n\nConfig fields carry caarlos0/env struct tags. Populate via environment variables\nor construct directly:\n\n\tcfg := cachevalkey.Config{\n\t Addrs: []string{\"localhost:6379\"},\n\t}",
|
||
"goVersion": "1.26",
|
||
"dependsOn": [
|
||
"contracts",
|
||
"core"
|
||
],
|
||
"subPackages": [
|
||
{
|
||
"name": "",
|
||
"importPath": "code.nochebuena.dev/einherjar/cache-valkey",
|
||
"doc": "Package cachevalkey provides a lifecycle-aware Valkey client with health checks\nand three adapters that integrate with other Einherjar starters via duck typing.\n\n# Quick start\n\n\tvk := cachevalkey.New(logger, cfg)\n\tlc.Append(vk) // lifecycle: OnInit → OnStart → OnStop\n\t// vk satisfies observability.Checkable (PING-based, LevelDegraded) — wire a health endpoint:\n\tsrv.Get(\"/health\", health.NewHandler(logger, vk).ServeHTTP)\n\n# Adapters\n\nEach adapter wraps the Provider and satisfies one interface in another module.\nGo's structural typing handles the assignment — no cast required:\n\n\tpermCache := cachevalkey.NewPermissionCache(vk) // → auth/rbac.Cache\n\trateLimiter := cachevalkey.NewRateLimiterStore(vk, time.Second, 100) // → web/mw.RateLimiterStore\n\tblacklist := cachevalkey.NewBlacklist(vk) // → auth-jwt.Blacklist\n\n# Configuration\n\nConfig fields carry caarlos0/env struct tags. Populate via environment variables\nor construct directly:\n\n\tcfg := cachevalkey.Config{\n\t Addrs: []string{\"localhost:6379\"},\n\t}"
|
||
}
|
||
],
|
||
"symbols": [
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "Blacklist",
|
||
"signature": "type Blacklist struct",
|
||
"doc": "Blacklist is a Valkey-backed JWT refresh token revocation list.\nSatisfies auth-jwt.Blacklist via duck typing — no import of that package required.\n\nJTIs are stored as keys with their remaining TTL; the entry expires naturally\nwhen the token would have expired. Keys are stored as-is (JTIs are UUIDs and\ndo not collide with permission cache keys).",
|
||
"file": "blacklist.go",
|
||
"line": 23,
|
||
"fields": [
|
||
{
|
||
"name": "provider",
|
||
"type": "Provider"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewBlacklist",
|
||
"signature": "func NewBlacklist(p Provider) *Blacklist",
|
||
"doc": "NewBlacklist returns a Blacklist backed by p.",
|
||
"file": "blacklist.go",
|
||
"line": 28
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "Blacklist.IsRevoked",
|
||
"signature": "func (b *Blacklist) IsRevoked(ctx context.Context, jti string) (bool, error)",
|
||
"doc": "IsRevoked reports whether the refresh token identified by jti has been revoked.",
|
||
"file": "blacklist.go",
|
||
"line": 33
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "Blacklist.Revoke",
|
||
"signature": "func (b *Blacklist) Revoke(ctx context.Context, jti string, ttl time.Duration) error",
|
||
"doc": "Revoke marks jti as revoked for the given TTL.\nTTL should match the token's remaining lifetime so the entry expires naturally.",
|
||
"file": "blacklist.go",
|
||
"line": 39
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Component",
|
||
"signature": "type Component interface",
|
||
"doc": "Component is the full cache-valkey capability: lifecycle management, health checks,\nand all Provider operations.\nAppend it to a launcher, and wire a health endpoint (it satisfies\nobservability.Checkable):\n\n\tlc.Append(vk)\n\tsrv.Get(\"/health\", health.NewHandler(logger, vk).ServeHTTP)",
|
||
"file": "component.go",
|
||
"line": 15,
|
||
"methods": [
|
||
{
|
||
"signature": "lifecycle.Component"
|
||
},
|
||
{
|
||
"signature": "observability.Checkable"
|
||
},
|
||
{
|
||
"signature": "observability.Identifiable"
|
||
},
|
||
{
|
||
"signature": "Provider"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "New",
|
||
"signature": "func New(logger logging.Logger, cfg Config) Component",
|
||
"doc": "New returns a Component backed by the given config.\nRegister the returned value with launcher and health before starting.",
|
||
"file": "new.go",
|
||
"line": 20
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "Config",
|
||
"signature": "type Config struct",
|
||
"doc": "Config holds Valkey connection settings.\nFields carry caarlos0/env struct tags; this module does not import that library directly.",
|
||
"file": "config.go",
|
||
"line": 5,
|
||
"fields": [
|
||
{
|
||
"name": "Addrs",
|
||
"type": "[]string",
|
||
"tag": "env:\"EINHERJAR_VALKEY_ADDRS,required\" envSeparator:\",\""
|
||
},
|
||
{
|
||
"name": "Password",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_VALKEY_PASSWORD\""
|
||
},
|
||
{
|
||
"name": "SelectDB",
|
||
"type": "int",
|
||
"tag": "env:\"EINHERJAR_VALKEY_DB\" envDefault:\"0\""
|
||
},
|
||
{
|
||
"name": "CacheSizeEachConn",
|
||
"type": "int",
|
||
"tag": "env:\"EINHERJAR_VALKEY_CLIENT_CACHE_MB\" envDefault:\"0\"",
|
||
"doc": "MB; 0 = disable"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "PermissionCache",
|
||
"signature": "type PermissionCache struct",
|
||
"doc": "PermissionCache is a Valkey-backed cache for int64 permission bitmasks.\nSatisfies auth/rbac.Cache via duck typing — no import of that package required.\n\nValues are stored as decimal strings and parsed back to int64 on read.",
|
||
"file": "permission_cache.go",
|
||
"line": 24,
|
||
"fields": [
|
||
{
|
||
"name": "provider",
|
||
"type": "Provider"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewPermissionCache",
|
||
"signature": "func NewPermissionCache(p Provider) *PermissionCache",
|
||
"doc": "NewPermissionCache returns a PermissionCache backed by p.",
|
||
"file": "permission_cache.go",
|
||
"line": 29
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "PermissionCache.Get",
|
||
"signature": "func (c *PermissionCache) Get(ctx context.Context, key string) (int64, bool, error)",
|
||
"doc": "Get retrieves a permission bitmask. Returns (0, false, nil) on cache miss.",
|
||
"file": "permission_cache.go",
|
||
"line": 34
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "PermissionCache.Set",
|
||
"signature": "func (c *PermissionCache) Set(ctx context.Context, key string, value int64, ttl time.Duration) error",
|
||
"doc": "Set stores a permission bitmask with the given TTL.",
|
||
"file": "permission_cache.go",
|
||
"line": 47
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Provider",
|
||
"signature": "type Provider interface",
|
||
"doc": "Provider wraps the most common Valkey operations using only standard Go types.\nCallers that need operations beyond this interface call Native().\nComponent satisfies Provider — pass the result of New() wherever a Provider is expected.",
|
||
"file": "provider.go",
|
||
"line": 13,
|
||
"methods": [
|
||
{
|
||
"name": "Get",
|
||
"signature": "Get(ctx context.Context, key string) (string, bool, error)",
|
||
"doc": "Get retrieves the string value of key. Returns (\"\", false, nil) on cache miss."
|
||
},
|
||
{
|
||
"name": "Set",
|
||
"signature": "Set(ctx context.Context, key string, value string, ttl time.Duration) error",
|
||
"doc": "Set stores value under key. Pass 0 ttl for no expiry."
|
||
},
|
||
{
|
||
"name": "Del",
|
||
"signature": "Del(ctx context.Context, keys ...string) error",
|
||
"doc": "Del removes one or more keys. Silently skips non-existent keys."
|
||
},
|
||
{
|
||
"name": "Exists",
|
||
"signature": "Exists(ctx context.Context, key string) (bool, error)",
|
||
"doc": "Exists reports whether key is present."
|
||
},
|
||
{
|
||
"name": "Expire",
|
||
"signature": "Expire(ctx context.Context, key string, ttl time.Duration) error",
|
||
"doc": "Expire sets a TTL on an existing key."
|
||
},
|
||
{
|
||
"name": "IncrWithTTL",
|
||
"signature": "IncrWithTTL(ctx context.Context, key string, ttl time.Duration) (int64, error)",
|
||
"doc": "IncrWithTTL atomically increments the integer counter at key and sets\nits expiry on first increment. Returns the new counter value.\nIf key does not exist it is created with value 1."
|
||
},
|
||
{
|
||
"name": "Native",
|
||
"signature": "Native() vk.Client",
|
||
"doc": "Native returns the underlying valkey-go client for operations not in Provider."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "RateLimiterStore",
|
||
"signature": "type RateLimiterStore struct",
|
||
"doc": "RateLimiterStore is a Valkey-backed fixed-window rate limiter.\nSatisfies web/mw.RateLimiterStore via duck typing — no import of that package required.\n\nwindow is the length of the time window; limit is the maximum number of requests\nallowed within that window. When the store is temporarily unavailable, Allow returns\n(false, err); the web middleware fails open on non-nil errors.",
|
||
"file": "rate_limiter_store.go",
|
||
"line": 22,
|
||
"fields": [
|
||
{
|
||
"name": "provider",
|
||
"type": "Provider"
|
||
},
|
||
{
|
||
"name": "window",
|
||
"type": "time.Duration"
|
||
},
|
||
{
|
||
"name": "limit",
|
||
"type": "int64"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewRateLimiterStore",
|
||
"signature": "func NewRateLimiterStore(p Provider, window time.Duration, limit int64) *RateLimiterStore",
|
||
"doc": "NewRateLimiterStore returns a RateLimiterStore backed by p.\nwindow is the rate limit window; limit is the maximum requests per window.",
|
||
"file": "rate_limiter_store.go",
|
||
"line": 30
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "RateLimiterStore.Allow",
|
||
"signature": "func (s *RateLimiterStore) Allow(ctx context.Context, key string) (bool, error)",
|
||
"doc": "Allow reports whether the request identified by key is within the rate limit.\nUses a fixed-window counter stored in Valkey (atomically via Lua).",
|
||
"file": "rate_limiter_store.go",
|
||
"line": 36
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "blacklistShape",
|
||
"signature": "type blacklistShape interface",
|
||
"doc": "blacklistShape mirrors auth-jwt.Blacklist for compile-time duck-type verification.\nCache-valkey must not import auth-jwt (D-1), so the shape is defined locally.",
|
||
"file": "blacklist.go",
|
||
"line": 10,
|
||
"methods": [
|
||
{
|
||
"name": "IsRevoked",
|
||
"signature": "IsRevoked(ctx context.Context, jti string) (bool, error)"
|
||
},
|
||
{
|
||
"name": "Revoke",
|
||
"signature": "Revoke(ctx context.Context, jti string, ttl time.Duration) error"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "rateLimiterStoreShape",
|
||
"signature": "type rateLimiterStoreShape interface",
|
||
"doc": "rateLimiterStoreShape mirrors web/mw.RateLimiterStore for compile-time duck-type verification.\nCache-valkey must not import web (D-1), so the shape is defined locally.",
|
||
"file": "rate_limiter_store.go",
|
||
"line": 10,
|
||
"methods": [
|
||
{
|
||
"name": "Allow",
|
||
"signature": "Allow(ctx context.Context, key string) (bool, error)"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "rbacCacheShape",
|
||
"signature": "type rbacCacheShape interface",
|
||
"doc": "rbacCacheShape mirrors auth/rbac.Cache for compile-time duck-type verification.\nCache-valkey must not import auth/rbac (D-1), so the shape is defined locally.",
|
||
"file": "permission_cache.go",
|
||
"line": 13,
|
||
"methods": [
|
||
{
|
||
"name": "Get",
|
||
"signature": "Get(ctx context.Context, key string) (int64, bool, error)"
|
||
},
|
||
{
|
||
"name": "Set",
|
||
"signature": "Set(ctx context.Context, key string, value int64, ttl time.Duration) error"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "vkImpl",
|
||
"signature": "type vkImpl struct",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 24,
|
||
"fields": [
|
||
{
|
||
"name": "cfg",
|
||
"type": "Config"
|
||
},
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
},
|
||
{
|
||
"name": "client",
|
||
"type": "vk.Client"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "vkImpl.Del",
|
||
"signature": "func (v *vkImpl) Del(ctx context.Context, keys ...string) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 101
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "vkImpl.Exists",
|
||
"signature": "func (v *vkImpl) Exists(ctx context.Context, key string) (bool, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 108
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "vkImpl.Expire",
|
||
"signature": "func (v *vkImpl) Expire(ctx context.Context, key string, ttl time.Duration) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 116
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "vkImpl.Get",
|
||
"signature": "func (v *vkImpl) Get(ctx context.Context, key string) (string, bool, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 77
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "vkImpl.HealthCheck",
|
||
"signature": "func (v *vkImpl) HealthCheck(ctx context.Context) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 70
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "vkImpl.IncrWithTTL",
|
||
"signature": "func (v *vkImpl) IncrWithTTL(ctx context.Context, key string, ttl time.Duration) (int64, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 129
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "vkImpl.ModulePath",
|
||
"signature": "func (v *vkImpl) ModulePath() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 7
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "vkImpl.ModuleVersion",
|
||
"signature": "func (v *vkImpl) ModuleVersion() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 9
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "vkImpl.Name",
|
||
"signature": "func (v *vkImpl) Name() string",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 66
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "vkImpl.Native",
|
||
"signature": "func (v *vkImpl) Native() vk.Client",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 68
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "vkImpl.OnInit",
|
||
"signature": "func (v *vkImpl) OnInit() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 30
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "vkImpl.OnStart",
|
||
"signature": "func (v *vkImpl) OnStart() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 47
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "vkImpl.OnStop",
|
||
"signature": "func (v *vkImpl) OnStop() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 58
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "vkImpl.Priority",
|
||
"signature": "func (v *vkImpl) Priority() observability.Level",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 67
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "vkImpl.Set",
|
||
"signature": "func (v *vkImpl) Set(ctx context.Context, key string, value string, ttl time.Duration) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 88
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "const",
|
||
"name": "luaIncrWithTTL",
|
||
"signature": "const luaIncrWithTTL = `local count = redis.call('INCR', KEYS[1])\nif count == 1 then\n redis.call('EXPIRE', KEYS[1], ARGV[1])\nend\nreturn count`",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 123
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "const",
|
||
"name": "modulePath",
|
||
"signature": "const modulePath = \"code.nochebuena.dev/einherjar/cache-valkey\"",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 5
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"kind": "var",
|
||
"name": "_",
|
||
"signature": "var _ observability.Identifiable = (*vkImpl)(nil)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 16
|
||
}
|
||
],
|
||
"adrs": null,
|
||
"examples": [
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"title": "Quick start",
|
||
"code": "import cachevalkey \"code.nochebuena.dev/einherjar/cache-valkey\"\n\nvk := cachevalkey.New(logger, cachevalkey.Config{\n Addrs: []string{\"localhost:6379\"},\n})\nlc.Append(vk) // OnInit → OnStart → OnStop\n// vk is observability.Checkable (PING-based, LevelDegraded):\nsrv.Get(\"/health\", health.NewHandler(logger, vk).ServeHTTP)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"title": "Connecting to other modules",
|
||
"code": "// --- 1. Create the component (lifecycle + health + Provider) ---\nvk := cachevalkey.New(logger, cfg)\nlc.Append(vk)\nsrv.Get(\"/health\", health.NewHandler(logger, vk).ServeHTTP)\n\n// --- 2. Create adapters (one per consumer interface) ---\n// Each adapter wraps vk (a Provider) and satisfies one interface in another module.\n// No import of auth, web, or auth-jwt is needed here.\npermCache := cachevalkey.NewPermissionCache(vk)\nrateLimiter := cachevalkey.NewRateLimiterStore(vk, time.Second, 100)\nblacklist := cachevalkey.NewBlacklist(vk)\n\n// --- 3. Wire adapters into consuming modules ---\n// Go's structural typing handles the interface assignment — no cast required.\n\n// auth: permission cache (satisfies rbac.Cache)\npermProvider := rbac.NewCachedPermissionProvider(permCache, baseProvider, rbac.CacheConfig{TTL: 5 * time.Minute})\n\n// web: rate limiter (satisfies mw.RateLimiterStore)\nrouter.Use(mw.IPRateLimit(rateLimiter))\n\n// auth-jwt: refresh token blacklist (satisfies authjwt.Blacklist)\npair, err := authjwt.RefreshTokenPair(ctx, signer, oldRefreshToken, blacklist, tokenCfg, newClaims)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"subPackage": "",
|
||
"title": "Dependency graph",
|
||
"code": "cache-valkey\n├── contracts v1.0.0 (lifecycle.Component, observability.Checkable, logging.Logger)\n├── core v1.0.0 (xerrors)\n└── valkey-go v1.0.54 (native client)",
|
||
"language": ""
|
||
}
|
||
],
|
||
"compliance": {
|
||
"interfaceAsserts": [
|
||
{
|
||
"module": "cache-valkey",
|
||
"interface": "rbacCacheShape",
|
||
"impl": "(*cachevalkey.PermissionCache)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 88
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"interface": "rateLimiterStoreShape",
|
||
"impl": "(*cachevalkey.RateLimiterStore)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 89
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"interface": "blacklistShape",
|
||
"impl": "(*cachevalkey.Blacklist)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 90
|
||
}
|
||
],
|
||
"tests": [
|
||
{
|
||
"module": "cache-valkey",
|
||
"name": "TestAtMostOneExportedTypePerFile",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 25
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"name": "TestPermissionCache_Get_Hit",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 162
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"name": "TestPermissionCache_Get_Miss",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 177
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"name": "TestPermissionCache_Get_ParseError",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 192
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"name": "TestPermissionCache_Get_StoreError",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 201
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"name": "TestPermissionCache_Set",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 211
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"name": "TestPermissionCache_Set_Error",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 229
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"name": "TestRateLimiterStore_Allow_UnderLimit",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 243
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"name": "TestRateLimiterStore_Allow_AtLimit",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 255
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"name": "TestRateLimiterStore_Allow_OverLimit",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 267
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"name": "TestRateLimiterStore_Allow_Error",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 279
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"name": "TestRateLimiterStore_Allow_PassesWindowToIncrWithTTL",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 292
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"name": "TestBlacklist_IsRevoked_True",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 306
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"name": "TestBlacklist_IsRevoked_False",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 318
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"name": "TestBlacklist_IsRevoked_Error",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 330
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"name": "TestBlacklist_Revoke",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 340
|
||
},
|
||
{
|
||
"module": "cache-valkey",
|
||
"name": "TestBlacklist_Revoke_Error",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 358
|
||
}
|
||
]
|
||
},
|
||
"readme": "# einherjar/cache-valkey\n\n[](https://code.nochebuena.dev/einherjar/cache-valkey)\n[](LICENSE)\n[](https://go.dev)\n[]()\n\n\u003e Speed is not a virtue. It is the difference between arriving and never arriving.\n\n`code.nochebuena.dev/einherjar/cache-valkey` is the Valkey cache component of the Einherjar framework. It wraps `valkey-go` behind a lifecycle-aware `Component` and ships three adapters — permission cache, rate limiter, and JWT blacklist — that satisfy interfaces in `auth`, `web`, and `auth-jwt` via Go's structural typing, with no cross-module import required.\n\n## Quick start\n\n```go\nimport cachevalkey \"code.nochebuena.dev/einherjar/cache-valkey\"\n\nvk := cachevalkey.New(logger, cachevalkey.Config{\n Addrs: []string{\"localhost:6379\"},\n})\nlc.Append(vk) // OnInit → OnStart → OnStop\n// vk is observability.Checkable (PING-based, LevelDegraded):\nsrv.Get(\"/health\", health.NewHandler(logger, vk).ServeHTTP)\n```\n\n## Connecting to other modules\n\nGo's structural typing handles interface assignment across module boundaries —\nno cast, no glue interface required. Create adapters from the component and pass\nthem directly to the consuming modules:\n\n```go\n// --- 1. Create the component (lifecycle + health + Provider) ---\nvk := cachevalkey.New(logger, cfg)\nlc.Append(vk)\nsrv.Get(\"/health\", health.NewHandler(logger, vk).ServeHTTP)\n\n// --- 2. Create adapters (one per consumer interface) ---\n// Each adapter wraps vk (a Provider) and satisfies one interface in another module.\n// No import of auth, web, or auth-jwt is needed here.\npermCache := cachevalkey.NewPermissionCache(vk)\nrateLimiter := cachevalkey.NewRateLimiterStore(vk, time.Second, 100)\nblacklist := cachevalkey.NewBlacklist(vk)\n\n// --- 3. Wire adapters into consuming modules ---\n// Go's structural typing handles the interface assignment — no cast required.\n\n// auth: permission cache (satisfies rbac.Cache)\npermProvider := rbac.NewCachedPermissionProvider(permCache, baseProvider, rbac.CacheConfig{TTL: 5 * time.Minute})\n\n// web: rate limiter (satisfies mw.RateLimiterStore)\nrouter.Use(mw.IPRateLimit(rateLimiter))\n\n// auth-jwt: refresh token blacklist (satisfies authjwt.Blacklist)\npair, err := authjwt.RefreshTokenPair(ctx, signer, oldRefreshToken, blacklist, tokenCfg, newClaims)\n```\n\nThe compile-time duck-type checks in `compliance_test.go` verify that each adapter\nsatisfies its target interface. If those checks compile, the wiring above is guaranteed\nto work.\n\n## Configuration\n\n| Environment variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_VALKEY_ADDRS` | Yes | — | Comma-separated `host:port` addresses |\n| `EINHERJAR_VALKEY_PASSWORD` | No | `\"\"` | Auth password |\n| `EINHERJAR_VALKEY_DB` | No | `0` | Database index |\n| `EINHERJAR_VALKEY_CLIENT_CACHE_MB` | No | `0` | Client-side cache per connection in MB (0 = disabled) |\n\n## API\n\n### Provider interface\n\n`Component` satisfies `Provider`. Pass the result of `New()` wherever a `Provider`\nis expected.\n\n| Method | Description |\n|---|---|\n| `Get(ctx, key) (string, bool, error)` | GET key; returns `(\"\", false, nil)` on miss |\n| `Set(ctx, key, value, ttl) error` | SET key value [EX ttl]; `ttl=0` = no expiry |\n| `Del(ctx, keys...) error` | DEL one or more keys |\n| `Exists(ctx, key) (bool, error)` | EXISTS key |\n| `Expire(ctx, key, ttl) error` | EXPIRE key ttl |\n| `IncrWithTTL(ctx, key, ttl) (int64, error)` | Atomic INCR + EXPIRE on first increment (Lua) |\n| `Native() vk.Client` | Raw valkey-go client for operations not in Provider |\n\n### Adapters\n\n| Constructor | Satisfies | Description |\n|---|---|---|\n| `NewPermissionCache(Provider)` | `auth/rbac.Cache` | Stores int64 permission bitmasks as decimal strings |\n| `NewRateLimiterStore(Provider, window, limit)` | `web/mw.RateLimiterStore` | Fixed-window counter; Lua atomic increment |\n| `NewBlacklist(Provider)` | `auth-jwt.Blacklist` | JWT JTI revocation via SET/EXISTS |\n\n## Dependency graph\n\n```\ncache-valkey\n├── contracts v1.0.0 (lifecycle.Component, observability.Checkable, logging.Logger)\n├── core v1.0.0 (xerrors)\n└── valkey-go v1.0.54 (native client)\n```\n\nNo dependency on `web`, `auth`, or `auth-jwt`.\nThe three adapters satisfy interfaces in those modules via duck typing.\n",
|
||
"changelog": "# Changelog — cache-valkey\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected doc examples plus framework version alignment.\n\n### Fixed\n\n- Package doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`. Verified by compiling against the real API.\n\n### Changed\n\n- Bumped `contracts` and `core` to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nCoordinated framework version alignment — released in lockstep at v1.1.0.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0 via `go get` + `go mod tidy`.\n No code or API changes.\n\n## [1.0.0] — 2026-05-28\n\n### Added\n\n- `Provider` interface: six common Valkey operations (`Get`, `Set`, `Del`, `Exists`,\n `Expire`, `IncrWithTTL`) using only standard Go types, plus `Native() vk.Client`\n escape hatch.\n- `Component` interface: `Provider` + `lifecycle.Component` + `observability.Checkable`.\n- `Config` struct with `EINHERJAR_VALKEY_*` env tags (caarlos0/env compatible).\n- `New(logger, cfg) Component` factory — lifecycle-aware Valkey client.\n- `NewPermissionCache(Provider) *PermissionCache` — satisfies `auth/rbac.Cache`\n via duck typing.\n- `NewRateLimiterStore(Provider, window, limit) *RateLimiterStore` — satisfies\n `web/mw.RateLimiterStore` via duck typing; fixed-window Lua counter.\n- `NewBlacklist(Provider) *Blacklist` — satisfies `auth-jwt.Blacklist` via duck typing.\n"
|
||
},
|
||
{
|
||
"name": "contracts",
|
||
"importPath": "code.nochebuena.dev/einherjar/contracts",
|
||
"purpose": "*For those who come after.*",
|
||
"doc": "Package contracts is the root of the Einherjar contracts module.\nAll framework contracts are defined in sub-packages:\nlifecycle, observability, logging, errs, and security.\nThis package contains no exported symbols.",
|
||
"goVersion": "1.26",
|
||
"dependsOn": [],
|
||
"subPackages": [
|
||
{
|
||
"name": "",
|
||
"importPath": "code.nochebuena.dev/einherjar/contracts",
|
||
"doc": "Package contracts is the root of the Einherjar contracts module.\nAll framework contracts are defined in sub-packages:\nlifecycle, observability, logging, errs, and security.\nThis package contains no exported symbols."
|
||
},
|
||
{
|
||
"name": "errs",
|
||
"importPath": "code.nochebuena.dev/einherjar/contracts/errs",
|
||
"doc": "Package errs defines the interfaces implemented by structured errors in the\nEinherjar framework. These interfaces replace the private duck-typed bridges\npreviously used between logz and xerrors, enforcing the contract at compile time."
|
||
},
|
||
{
|
||
"name": "lifecycle",
|
||
"importPath": "code.nochebuena.dev/einherjar/contracts/lifecycle",
|
||
"doc": "Package lifecycle defines the Component interface implemented by all managed\ninfrastructure components in the Einherjar framework."
|
||
},
|
||
{
|
||
"name": "logging",
|
||
"importPath": "code.nochebuena.dev/einherjar/contracts/logging",
|
||
"doc": "Package logging defines the Logger interface for structured, leveled logging\nacross the Einherjar framework."
|
||
},
|
||
{
|
||
"name": "observability",
|
||
"importPath": "code.nochebuena.dev/einherjar/contracts/observability",
|
||
"doc": "Package observability defines the Checkable interface for infrastructure health\nreporting and the Level type that classifies component criticality."
|
||
},
|
||
{
|
||
"name": "security",
|
||
"importPath": "code.nochebuena.dev/einherjar/contracts/security",
|
||
"doc": "Package security defines the Identity type representing an authenticated\nprincipal, the PermissionProvider interface for resolving access masks, and\nthe Permission and PermissionMask types for capability modelling."
|
||
}
|
||
],
|
||
"symbols": [
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "errs",
|
||
"kind": "interface",
|
||
"name": "CodedError",
|
||
"signature": "type CodedError interface",
|
||
"doc": "CodedError is implemented by errors that expose a machine-readable error code.\nThe core starter's Logger implementation consumes this interface to append\nerror_code to structured log records. The core starter's xerrors implementation\nsatisfies it via its ErrorCode method.",
|
||
"file": "errs/coded_error.go",
|
||
"line": 7,
|
||
"methods": [
|
||
{
|
||
"name": "ErrorCode",
|
||
"signature": "ErrorCode() string",
|
||
"doc": "ErrorCode returns a machine-readable identifier for the error condition.\nValues are SCREAMING_SNAKE_CASE strings meaningful to frontend consumers\n(e.g. \"USER_NOT_FOUND\"). Internal errors must not carry a code."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "errs",
|
||
"kind": "interface",
|
||
"name": "ContextualError",
|
||
"signature": "type ContextualError interface",
|
||
"doc": "ContextualError is implemented by errors that expose structured key-value context\nfields. The core starter's Logger implementation consumes this interface to append\ncontext fields to structured log records. The core starter's xerrors implementation\nsatisfies it via its ErrorContext method.",
|
||
"file": "errs/contextual_error.go",
|
||
"line": 7,
|
||
"methods": [
|
||
{
|
||
"name": "ErrorContext",
|
||
"signature": "ErrorContext() map[string]any",
|
||
"doc": "ErrorContext returns a map of structured fields attached to this error.\nKeys are strings; values may be any type representable in a log record.\nThe returned map must not be modified by the caller."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "lifecycle",
|
||
"kind": "interface",
|
||
"name": "Component",
|
||
"signature": "type Component interface",
|
||
"doc": "Component is the lifecycle interface implemented by all managed infrastructure\ncomponents. The framework calls OnInit on every registered component, then runs\nBeforeStart hooks, then calls OnStart. OnStop is called in reverse registration\norder during graceful shutdown. Returning a non-nil error from any hook aborts\nthe phase and triggers shutdown.",
|
||
"file": "lifecycle/component.go",
|
||
"line": 8,
|
||
"methods": [
|
||
{
|
||
"name": "OnInit",
|
||
"signature": "OnInit() error",
|
||
"doc": "OnInit initializes the component: open connections, allocate resources.\nCalled sequentially for all components before any OnStart is invoked."
|
||
},
|
||
{
|
||
"name": "OnStart",
|
||
"signature": "OnStart() error",
|
||
"doc": "OnStart starts background services — goroutines, listeners, background loops.\nCalled after all OnInit calls succeed and all BeforeStart hooks have run."
|
||
},
|
||
{
|
||
"name": "OnStop",
|
||
"signature": "OnStop() error",
|
||
"doc": "OnStop stops the component and releases all resources.\nCalled in reverse registration order during graceful shutdown."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "logging",
|
||
"kind": "interface",
|
||
"name": "Logger",
|
||
"signature": "type Logger interface",
|
||
"doc": "Logger is the interface for structured, leveled logging.\nAll Einherjar starters accept Logger at their constructors and pass it\nto sub-components — never the concrete implementation.\nImplementations must be safe for concurrent use.",
|
||
"file": "logging/logger.go",
|
||
"line": 9,
|
||
"methods": [
|
||
{
|
||
"name": "Debug",
|
||
"signature": "Debug(msg string, args ...any)",
|
||
"doc": "Debug logs a message at DEBUG level."
|
||
},
|
||
{
|
||
"name": "Info",
|
||
"signature": "Info(msg string, args ...any)",
|
||
"doc": "Info logs a message at INFO level."
|
||
},
|
||
{
|
||
"name": "Warn",
|
||
"signature": "Warn(msg string, args ...any)",
|
||
"doc": "Warn logs a message at WARN level."
|
||
},
|
||
{
|
||
"name": "Error",
|
||
"signature": "Error(msg string, err error, args ...any)",
|
||
"doc": "Error logs a message at ERROR level. err may be nil.\nImplementations should detect errs.CodedError and errs.ContextualError\nand append their fields automatically to the log record."
|
||
},
|
||
{
|
||
"name": "With",
|
||
"signature": "With(args ...any) Logger",
|
||
"doc": "With returns a new Logger with the given key-value attributes pre-attached\nto every subsequent log record produced by the returned logger."
|
||
},
|
||
{
|
||
"name": "WithContext",
|
||
"signature": "WithContext(ctx context.Context) Logger",
|
||
"doc": "WithContext returns a new Logger enriched with request-scoped fields stored\nin ctx. Safe to call with a nil context — returns the receiver unchanged."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "observability",
|
||
"kind": "interface",
|
||
"name": "Checkable",
|
||
"signature": "type Checkable interface",
|
||
"doc": "Checkable is the interface implemented by infrastructure components that report\ntheir health status to a health handler. Pass Checkable components to the web\nstarter's health handler — the handler calls each concurrently with a deadline.",
|
||
"file": "observability/checkable.go",
|
||
"line": 8,
|
||
"methods": [
|
||
{
|
||
"name": "HealthCheck",
|
||
"signature": "HealthCheck(ctx context.Context) error",
|
||
"doc": "HealthCheck performs a connectivity or liveness probe for the component.\nReturns nil when the component is healthy, a non-nil error otherwise.\nImplementations must respect ctx cancellation and return promptly on timeout."
|
||
},
|
||
{
|
||
"name": "Name",
|
||
"signature": "Name() string",
|
||
"doc": "Name returns the component's display name used in health check responses."
|
||
},
|
||
{
|
||
"name": "Priority",
|
||
"signature": "Priority() Level",
|
||
"doc": "Priority returns the criticality level of this component."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "observability",
|
||
"kind": "interface",
|
||
"name": "Identifiable",
|
||
"signature": "type Identifiable interface",
|
||
"doc": "Identifiable is implemented by infrastructure components that can report their\nmodule identity — the import path and released version.\nThe launcher reads this interface from all registered components to print\nthe loaded-module list after the startup banner.",
|
||
"file": "observability/identifiable.go",
|
||
"line": 7,
|
||
"methods": [
|
||
{
|
||
"name": "ModulePath",
|
||
"signature": "ModulePath() string",
|
||
"doc": "ModulePath returns the fully-qualified Go module import path.\nExample: \"code.nochebuena.dev/einherjar/web\""
|
||
},
|
||
{
|
||
"name": "ModuleVersion",
|
||
"signature": "ModuleVersion() string",
|
||
"doc": "ModuleVersion returns the version this module was compiled at.\nReturns \"(devel)\" when the binary was built from a local workspace."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "observability",
|
||
"kind": "type",
|
||
"name": "Level",
|
||
"signature": "type Level int",
|
||
"doc": "Level classifies the criticality of a component to the overall application health.\nThe zero value is LevelCritical — a safe default that treats unknown components\nas essential.",
|
||
"file": "observability/level.go",
|
||
"line": 6
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "type",
|
||
"name": "Identity",
|
||
"signature": "type Identity struct",
|
||
"doc": "Identity represents the authenticated principal for a request.\n\nIdentity is a value type — always copied, never a pointer — to prevent\nnil-check burden and accidental mutation of a shared context value.\nConstruction follows a two-step pattern: NewIdentity populates authentication\ndata from the token (uid, name, email); WithTenant optionally enriches with a\ntenant ID in a later middleware step, returning a new value without mutating\nthe original.",
|
||
"file": "security/identity.go",
|
||
"line": 13,
|
||
"fields": [
|
||
{
|
||
"name": "UID",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"name": "TenantID",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"name": "DisplayName",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"name": "Email",
|
||
"type": "string"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "func",
|
||
"name": "FromContext",
|
||
"signature": "func FromContext(ctx context.Context) (Identity, bool)",
|
||
"doc": "FromContext retrieves the Identity stored by [SetInContext] or [SetBagInContext].\nReturns the zero-value Identity and false if no identity is present in ctx.",
|
||
"file": "security/identity.go",
|
||
"line": 53
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "func",
|
||
"name": "NewIdentity",
|
||
"signature": "func NewIdentity(uid, displayName, email string) Identity",
|
||
"doc": "NewIdentity creates an Identity from token authentication data.\nTenantID is left empty — populate it later with WithTenant once the enrichment\nmiddleware has resolved it from the request context.",
|
||
"file": "security/identity.go",
|
||
"line": 29
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "method",
|
||
"name": "Identity.WithTenant",
|
||
"signature": "func (i Identity) WithTenant(id string) Identity",
|
||
"doc": "WithTenant returns a copy of the Identity with TenantID set to id.\nThe receiver is not mutated — safe to call from concurrent middleware.",
|
||
"file": "security/identity.go",
|
||
"line": 39
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "type",
|
||
"name": "Permission",
|
||
"signature": "type Permission int64",
|
||
"doc": "Permission is a named bit position (0–62) representing a single capability.\n\nApplications define their own permission constants using this type:\n\n\tconst (\n\t Read security.Permission = 0\n\t Write security.Permission = 1\n\t Delete security.Permission = 2\n\t)\n\nValid positions are 0 through MaxPermission (62). Values outside that range\nare silently ignored by PermissionMask.Has and PermissionMask.Grant.",
|
||
"file": "security/permission.go",
|
||
"line": 15
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "type",
|
||
"name": "PermissionMask",
|
||
"signature": "type PermissionMask int64",
|
||
"doc": "PermissionMask is a resolved bit-mask for a user on a specific resource.\nIt is returned by PermissionProvider.ResolveMask and inspected with Has.\nA zero value means no permissions are granted.",
|
||
"file": "security/permission_mask.go",
|
||
"line": 6
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "method",
|
||
"name": "PermissionMask.Grant",
|
||
"signature": "func (m PermissionMask) Grant(p Permission) PermissionMask",
|
||
"doc": "Grant returns a new mask with the bit for p set.\nThe receiver is not modified — safe to use in builder chains:\n\n\tmask := security.PermissionMask(0).Grant(Read).Grant(Write)",
|
||
"file": "security/permission_mask.go",
|
||
"line": 21
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "method",
|
||
"name": "PermissionMask.Has",
|
||
"signature": "func (m PermissionMask) Has(p Permission) bool",
|
||
"doc": "Has reports whether the given permission bit is set in the mask.\nReturns false for out-of-range values (p \u003c 0 or p \u003e MaxPermission).",
|
||
"file": "security/permission_mask.go",
|
||
"line": 10
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "interface",
|
||
"name": "PermissionProvider",
|
||
"signature": "type PermissionProvider interface",
|
||
"doc": "PermissionProvider resolves the permission mask for a user on a given resource.\n\nImplementations may call FromContext to retrieve the Identity (and its TenantID)\nwhen multi-tenancy is required — there is no need to thread tenantID as an\nexplicit parameter since it is already in the context.\n\nThe resource string identifies what is being accessed (e.g. \"orders\",\n\"invoices\"). Its meaning is defined by the application domain.",
|
||
"file": "security/permission_provider.go",
|
||
"line": 13,
|
||
"methods": [
|
||
{
|
||
"name": "ResolveMask",
|
||
"signature": "ResolveMask(ctx context.Context, uid, resource string) (PermissionMask, error)",
|
||
"doc": "ResolveMask returns the PermissionMask for uid on resource.\nA zero mask means no permissions are granted. Callers check individual\nbits with PermissionMask.Has using domain-defined Permission constants."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "type",
|
||
"name": "SecurityBag",
|
||
"signature": "type SecurityBag struct",
|
||
"doc": "SecurityBag is the request-scoped security context for a single request.\nIt carries the authenticated [Identity] alongside any additional attributes\ninjected during the enrichment phase (hardware IDs, grant codes, etc.).\n\nSecurityBag is a value type — all mutation methods return a new value without\nmodifying the receiver. The attribute map is copied on every [SecurityBag.With]\ncall to preserve this guarantee.\n\nThe framework defines no string key constants for bag attributes — all\nframework-known fields live as typed fields on [Identity]. Callers define\ntheir own constants to avoid collisions:\n\n\tconst KeyHardwareID = \"hardware_id\"\n\n\tbag.With(KeyHardwareID, hwID)\n\tval, ok := bag.Get(KeyHardwareID)",
|
||
"file": "security/security_bag.go",
|
||
"line": 19,
|
||
"fields": [
|
||
{
|
||
"name": "identity",
|
||
"type": "Identity"
|
||
},
|
||
{
|
||
"name": "attributes",
|
||
"type": "map[string]any"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "func",
|
||
"name": "BagFromContext",
|
||
"signature": "func BagFromContext(ctx context.Context) (SecurityBag, bool)",
|
||
"doc": "BagFromContext retrieves the [SecurityBag] stored by [SetBagInContext] or [SetInContext].\nReturns an empty SecurityBag and false if no bag is present in ctx.\nPermission providers use this to access both the Identity and any extra attributes\ninjected during enrichment.",
|
||
"file": "security/identity.go",
|
||
"line": 72
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "func",
|
||
"name": "NewSecurityBag",
|
||
"signature": "func NewSecurityBag(id Identity) SecurityBag",
|
||
"doc": "NewSecurityBag creates a SecurityBag wrapping id with an empty attribute map.",
|
||
"file": "security/security_bag.go",
|
||
"line": 25
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "method",
|
||
"name": "SecurityBag.Get",
|
||
"signature": "func (b SecurityBag) Get(key string) (any, bool)",
|
||
"doc": "Get returns the attribute stored under key and true, or (nil, false) if absent.",
|
||
"file": "security/security_bag.go",
|
||
"line": 41
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "method",
|
||
"name": "SecurityBag.Identity",
|
||
"signature": "func (b SecurityBag) Identity() Identity",
|
||
"doc": "Identity returns the authenticated Identity stored in the bag.",
|
||
"file": "security/security_bag.go",
|
||
"line": 30
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "method",
|
||
"name": "SecurityBag.With",
|
||
"signature": "func (b SecurityBag) With(key string, value any) SecurityBag",
|
||
"doc": "With returns a copy of the bag with key set to value.\nThe receiver is not modified — safe to call from concurrent middleware chains.",
|
||
"file": "security/security_bag.go",
|
||
"line": 48
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "method",
|
||
"name": "SecurityBag.WithIdentity",
|
||
"signature": "func (b SecurityBag) WithIdentity(id Identity) SecurityBag",
|
||
"doc": "WithIdentity returns a copy of the bag with the Identity replaced by id.\nUsed by bag enrichers that modify the Identity (e.g., [authmw.WithTenantHeader]).",
|
||
"file": "security/security_bag.go",
|
||
"line": 36
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "type",
|
||
"name": "authContextKey",
|
||
"signature": "type authContextKey struct",
|
||
"doc": "authContextKey is the unexported context key used to store Identity values.\nUsing a private type prevents collisions with keys from other packages.",
|
||
"file": "security/identity.go",
|
||
"line": 22
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "func",
|
||
"name": "SetBagInContext",
|
||
"signature": "func SetBagInContext(ctx context.Context, bag SecurityBag) context.Context",
|
||
"doc": "SetBagInContext stores bag in ctx and returns the enriched context.\nUse this when you need to attach request-level attributes beyond the Identity\n(hardware IDs, grant codes, etc.) via [SecurityBag.With].",
|
||
"file": "security/identity.go",
|
||
"line": 64
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "func",
|
||
"name": "SetInContext",
|
||
"signature": "func SetInContext(ctx context.Context, id Identity) context.Context",
|
||
"doc": "SetInContext stores id in ctx as a [SecurityBag] and returns the enriched context.\nCallers that need to attach additional request-level attributes should use\n[SetBagInContext] directly. [FromContext] continues to work unchanged.",
|
||
"file": "security/identity.go",
|
||
"line": 47
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "security",
|
||
"kind": "var",
|
||
"name": "authKey",
|
||
"signature": "var authKey = authContextKey{}",
|
||
"doc": "",
|
||
"file": "security/identity.go",
|
||
"line": 24
|
||
}
|
||
],
|
||
"adrs": [
|
||
{
|
||
"module": "contracts",
|
||
"id": "ADR-001",
|
||
"title": "ADR-001: errs Sub-package — Formalising the Error Enrichment Contract",
|
||
"body": "# ADR-001: errs Sub-package — Formalising the Error Enrichment Contract\n\n- **Date:** 2026-05-27\n- **Module:** `code.nochebuena.dev/einherjar/contracts`\n- **Status:** Accepted\n\n## Context\n\nIn micro-lib, `logz` and `xerrors` are decoupled through duck typing. `logz` defines\ntwo private interfaces internally:\n\n```go\n// inside logz — never exported\ntype errorWithCode interface {\n ErrorCode() string\n}\ntype errorWithContext interface {\n ErrorContext() map[string]any\n}\n```\n\n`xerrors.Err` happens to satisfy both. `logz` detects this at runtime via\n`errors.As`. The contract exists — it just lives nowhere. Any type that implements\n`ErrorCode() string` and `ErrorContext() map[string]any` gets the enrichment\nbehaviour, whether its author knew about `logz` or not.\n\nThis works in a flat library collection where both packages are authored by the same\nteam. It breaks down in a framework context for two reasons:\n\n1. **No canonical definition.** A developer implementing a custom error type that\n wants logger enrichment has no interface to implement against — they must read\n `logz`'s source code to discover the implicit contract.\n\n2. **Impossible to mock or test cleanly.** A test that wants to verify error\n enrichment behaviour cannot assert against a defined interface. It must rely on\n the duck-type resolution happening implicitly.\n\n## Decision\n\nIntroduce `contracts/errs` as a sub-package with two exported interfaces:\n\n```go\n// errs/coded_error.go\ntype CodedError interface {\n ErrorCode() string\n}\n\n// errs/contextual_error.go\ntype ContextualError interface {\n ErrorContext() map[string]any\n}\n```\n\n`core/logz` imports `contracts/errs` and checks against these interfaces explicitly\ninstead of defining private duck-typed equivalents. `core/xerrors` declares compile-time\nsatisfaction:\n\n```go\nvar _ errs.CodedError = (*Err)(nil)\nvar _ errs.ContextualError = (*Err)(nil)\n```\n\nThe clean separation is preserved — `logz` still does not import `xerrors`, and\n`xerrors` still does not import `logz`. Both now depend on `contracts/errs`, which\nhas zero dependencies of its own.\n\n## Why Two Interfaces, Not One\n\nThe interfaces are intentionally separate. ISP (Interface Segregation Principle)\nrequires that no consumer is forced to implement methods it does not use.\n\nAn error may carry a machine-readable code without structured context fields. An\nerror may carry structured context fields without a machine-readable code. Forcing\nboth into a single `RichError` interface would require implementors to provide both\n— a constraint that is not justified by the actual use cases.\n\n`logz` checks each independently via `errors.As`, which is exactly how the\nduck-typed version already worked. The two-interface shape matches the actual\nconsumption pattern.\n\n## Why in `contracts`, Not in `core`\n\n`contracts` is the only module guaranteed to have zero Einherjar dependencies. If\n`CodedError` and `ContextualError` lived in `core`, any module that wanted to\nimplement them would be forced to import `core` — pulling the launcher, logger, and\nvalidator into its dependency graph. That violates the Separated Interface pattern\nthat `contracts` exists to enforce.\n\n## Consequences\n\n**Easier:** Custom error types have a clear interface to implement against.\n`go doc code.nochebuena.dev/einherjar/contracts/errs` is the single authoritative\nanswer to \"what does my error need to provide for logger enrichment to work?\"\nCompile-time verification replaces implicit duck-type discovery.\n\n**Harder:** `errs` is a sub-package that did not exist in micro-lib. Developers\nmigrating from micro-lib must adopt these interfaces explicitly rather than relying\non the duck-type bridge. This is a one-time migration cost with no ongoing burden.\n\n**New obligations:** `CodedError.ErrorCode()` and `ContextualError.ErrorContext()`\nsignatures are permanent from this release. They may not be changed without a major\nversion bump on `contracts`.\n"
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"id": "ADR-002",
|
||
"title": "ADR-002: File-per-Type Naming Convention",
|
||
"body": "# ADR-002: File-per-Type Naming Convention\n\n- **Date:** 2026-05-27\n- **Module:** `code.nochebuena.dev/einherjar/contracts`\n- **Status:** Accepted\n\n## Context\n\nFramework-level ADR-004 establishes that `contracts` sub-packages contain one file\nper interface or type (CT-6). It does not specify how those files are named. Without\nan explicit convention, names tend to drift: `types.go`, `interfaces.go`, `models.go`,\nor `common.go` — filenames that say nothing about what is inside.\n\nThe naming problem compounds in `security`, which has five declarations across five\nfiles. A developer looking at the directory listing needs to know immediately which\nfile to open.\n\n## Decision\n\nEvery source file in `contracts` is named after the single type or interface it\ndeclares, converted to lowercase and snake_cased for multi-word names.\n\n| Declaration | File |\n|---|---|\n| `Component` | `component.go` |\n| `Checkable` | `checkable.go` |\n| `Level` | `level.go` |\n| `Logger` | `logger.go` |\n| `CodedError` | `coded_error.go` |\n| `ContextualError` | `contextual_error.go` |\n| `Identity` | `identity.go` |\n| `Permission` | `permission.go` |\n| `PermissionMask` | `permission_mask.go` |\n| `PermissionProvider` | `permission_provider.go` |\n\n`doc.go` files are the sole exception — they carry the package-level doc comment and\nexport nothing.\n\nConstants, package-level variables, and functions that are semantically part of a\ntype's API (constructors, context helpers, builder methods) coexist in the same file\nas the type they serve. They are not counted as separate declarations for CT-6\npurposes — CT-6 governs type declarations, not every exported symbol.\n\nExamples:\n- `level.go` declares `Level` and also defines `LevelCritical` and `LevelDegraded`\n- `permission.go` declares `Permission` and also defines `MaxPermission`\n- `identity.go` declares `Identity` and also defines `NewIdentity`, `WithTenant`,\n `SetInContext`, and `FromContext`\n\n## Alternatives Considered\n\n**Single `types.go` per sub-package.** Rejected — a file named `types.go` provides\nno information to a developer scanning a directory. They must open it to know what\nis inside.\n\n**Interface files named `interface.go` or `contract.go`.** Rejected — same reason.\nThe filename should answer \"what contract does this file define?\" not \"what kind of\nfile is this?\"\n\n**Separate files for each function and constant.** Rejected — excessive fragmentation.\nA constructor and the type it constructs are a single conceptual unit. Splitting them\nforces a developer to open two files to understand one thing.\n\n## Consequences\n\n**Easier:** `find . -name \"permission_mask.go\"` returns exactly the file that defines\n`PermissionMask`. A directory listing of `security/` reads as a vocabulary list for\nthat sub-package's domain. Code review diffs are unambiguous — a change to\n`permission_provider.go` is a change to the `PermissionProvider` contract.\n\n**Harder:** Multi-word type names require a deliberate naming decision at the file\nlevel. The snake_case rule eliminates ambiguity (`permission_mask.go` is the only\nvalid name for `PermissionMask`) but must be applied consistently across all modules.\n\n**New obligations:** Every Einherjar module inherits this convention. When a new type\nis added to any module, its file is named after the type. This is not optional — the\ncompliance test enforces the one-type-per-file structural invariant, and the naming\nconvention is the human-readable complement to that mechanical check.\n"
|
||
}
|
||
],
|
||
"examples": [
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "",
|
||
"title": "Module",
|
||
"code": "code.nochebuena.dev/einherjar/contracts",
|
||
"language": ""
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "",
|
||
"title": "Usage",
|
||
"code": "import (\n \"code.nochebuena.dev/einherjar/contracts/lifecycle\"\n \"code.nochebuena.dev/einherjar/contracts/logging\"\n \"code.nochebuena.dev/einherjar/contracts/observability\"\n \"code.nochebuena.dev/einherjar/contracts/errs\"\n \"code.nochebuena.dev/einherjar/contracts/security\"\n)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "",
|
||
"title": "Implementing a managed component",
|
||
"code": "// MyClient satisfies lifecycle.Component and observability.Checkable.\nvar _ lifecycle.Component = (*MyClient)(nil)\nvar _ observability.Checkable = (*MyClient)(nil)\n\nfunc (c *MyClient) OnInit() error { /* open connection */ }\nfunc (c *MyClient) OnStart() error { return nil }\nfunc (c *MyClient) OnStop() error { /* close connection */ }\nfunc (c *MyClient) HealthCheck(ctx context.Context) error { /* ping */ }\nfunc (c *MyClient) Name() string { return \"my-client\" }\nfunc (c *MyClient) Priority() observability.Level { return observability.LevelCritical }",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "",
|
||
"title": "Implementing structured errors",
|
||
"code": "// MyErr satisfies errs.CodedError and errs.ContextualError.\nvar _ errs.CodedError = (*MyErr)(nil)\nvar _ errs.ContextualError = (*MyErr)(nil)\n\nfunc (e *MyErr) ErrorCode() string { return e.code }\nfunc (e *MyErr) ErrorContext() map[string]any { return e.ctx }",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "",
|
||
"title": "Working with identity and permissions",
|
||
"code": "// Store identity after authentication.\nctx = security.SetInContext(ctx, security.NewIdentity(uid, name, email))\n\n// Retrieve identity downstream.\nid, ok := security.FromContext(ctx)\n\n// Resolve and check permissions.\nmask, err := provider.ResolveMask(ctx, id.UID, \"orders\")\nif !mask.Has(PermWrite) { /* deny */ }",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "",
|
||
"title": "Dependency Rules",
|
||
"code": "contracts\n └── core (absorbs launcher, logz, xerrors, valid)\n ├── web (absorbs httpserver, httpmw, httputil, health)\n │ └── auth\n │ ├── auth-jwt\n │ └── auth-firebase\n └── db-*, cache-*, storage-*, telemetry, worker, httpclient",
|
||
"language": ""
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"subPackage": "",
|
||
"title": "Compliance",
|
||
"code": "go build ./... # zero external dependencies\ngo vet ./...\ngo test ./... # structural (one type per file) + behavioural tests\ngofmt -l . # no output",
|
||
"language": "bash"
|
||
}
|
||
],
|
||
"compliance": {
|
||
"interfaceAsserts": [],
|
||
"tests": [
|
||
{
|
||
"module": "contracts",
|
||
"name": "TestOneExportPerFile",
|
||
"doc": "TestOneExportPerFile asserts that every non-doc, non-test .go source file in the\ncontracts module exports exactly one top-level declaration. This mechanically\nenforces CT-6: one file per interface or type.",
|
||
"file": "compliance_test.go",
|
||
"line": 20
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"name": "TestIdentityIsValueType",
|
||
"doc": "TestIdentityIsValueType verifies that Identity.WithTenant returns a new value\nwithout mutating the receiver.",
|
||
"file": "compliance_test.go",
|
||
"line": 80
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"name": "TestIdentityContextRoundtrip",
|
||
"doc": "TestIdentityContextRoundtrip verifies SetInContext and FromContext.",
|
||
"file": "compliance_test.go",
|
||
"line": 98
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"name": "TestFromContextMissing",
|
||
"doc": "TestFromContextMissing verifies FromContext returns zero value and false on an\nempty context.",
|
||
"file": "compliance_test.go",
|
||
"line": 115
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"name": "TestPermissionMaskRoundtrip",
|
||
"doc": "TestPermissionMaskRoundtrip verifies Grant and Has.",
|
||
"file": "compliance_test.go",
|
||
"line": 128
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"name": "TestPermissionMaskBoundaries",
|
||
"doc": "TestPermissionMaskBoundaries verifies out-of-range positions are rejected.",
|
||
"file": "compliance_test.go",
|
||
"line": 149
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"name": "TestSecurityBagNewAndGet",
|
||
"doc": "TestSecurityBagNewAndGet verifies construction and attribute access.",
|
||
"file": "compliance_test.go",
|
||
"line": 166
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"name": "TestSecurityBagWith",
|
||
"doc": "TestSecurityBagWith verifies that With stores a value and returns a new bag.",
|
||
"file": "compliance_test.go",
|
||
"line": 181
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"name": "TestSecurityBagImmutability",
|
||
"doc": "TestSecurityBagImmutability verifies that With does not mutate the original bag.",
|
||
"file": "compliance_test.go",
|
||
"line": 197
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"name": "TestSecurityBagWithIdentity",
|
||
"doc": "TestSecurityBagWithIdentity verifies WithIdentity replaces the Identity.",
|
||
"file": "compliance_test.go",
|
||
"line": 210
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"name": "TestBagContextRoundtrip",
|
||
"doc": "TestBagContextRoundtrip verifies SetBagInContext and BagFromContext.",
|
||
"file": "compliance_test.go",
|
||
"line": 228
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"name": "TestSetInContextReadableBag",
|
||
"doc": "TestSetInContextReadableBag verifies that SetInContext stores a SecurityBag\nreadable by BagFromContext (backward-compatible storage upgrade).",
|
||
"file": "compliance_test.go",
|
||
"line": 250
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"name": "TestSetBagInContextReadableIdentity",
|
||
"doc": "TestSetBagInContextReadableIdentity verifies that SetBagInContext stores a value\nreadable by FromContext (forward-compatible for existing callers).",
|
||
"file": "compliance_test.go",
|
||
"line": 267
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"name": "TestBagFromContextMissing",
|
||
"doc": "TestBagFromContextMissing verifies BagFromContext returns false on empty context.",
|
||
"file": "compliance_test.go",
|
||
"line": 284
|
||
},
|
||
{
|
||
"module": "contracts",
|
||
"name": "TestLevelCriticalIsZeroValue",
|
||
"doc": "TestLevelCriticalIsZeroValue verifies that the zero value of Level is LevelCritical.",
|
||
"file": "compliance_test.go",
|
||
"line": 294
|
||
}
|
||
]
|
||
},
|
||
"readme": "# einherjar/contracts\n\n[](https://code.nochebuena.dev/einherjar/contracts)\n[](LICENSE)\n[](https://go.dev)\n\n\u003e *For those who come after.*\n\nThe foundation of the Einherjar framework. Pure interfaces. Zero dependencies. A constitution, not a changelog.\n\n`contracts` defines the interfaces and minimal types that every Einherjar starter depends on. Nothing is above it in the dependency graph. Its interfaces, once published, are permanent — adding a method is a breaking change in Go, and breaking changes require a major version bump and coordinated updates across the entire framework.\n\n---\n\n## What Is Einherjar\n\nIn Norse mythology, the Einherjar are the chosen — warriors who fell in battle and were carried to Valhalla by the Valkyries. There they train, day after day, preparing for Ragnarök. They are not preserved for themselves. They are prepared for what comes after.\n\nThis framework carries that name deliberately. Every interface defined here, every ADR written, every pattern documented exists for the developer who will build on this code without ever being in the room where it was designed. No tribal knowledge. No implicit conventions. The documentation is the system.\n\n---\n\n## Module\n\n```\ncode.nochebuena.dev/einherjar/contracts\n```\n\n**Dependencies:** none. This module imports only the Go standard library.\n**Stability guarantee:** existing interface signatures are permanent. New interfaces may be added. Existing ones are not modified.\n\n---\n\n## Sub-packages\n\n| Package | Purpose |\n|---|---|\n| [`lifecycle`](lifecycle/) | `Component` — three-phase managed lifecycle (OnInit → OnStart → OnStop) |\n| [`observability`](observability/) | `Checkable` + `Level` — health reporting for infrastructure components |\n| [`logging`](logging/) | `Logger` — structured, leveled logging |\n| [`errs`](errs/) | `CodedError` + `ContextualError` — structured error enrichment consumed by the logger |\n| [`security`](security/) | `Identity`, `Permission`, `PermissionMask`, `PermissionProvider` — authentication and authorization primitives |\n\n---\n\n## Usage\n\nImport only the sub-packages you need. There is no root `contracts` package to import.\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/contracts/lifecycle\"\n \"code.nochebuena.dev/einherjar/contracts/logging\"\n \"code.nochebuena.dev/einherjar/contracts/observability\"\n \"code.nochebuena.dev/einherjar/contracts/errs\"\n \"code.nochebuena.dev/einherjar/contracts/security\"\n)\n```\n\n### Implementing a managed component\n\n```go\n// MyClient satisfies lifecycle.Component and observability.Checkable.\nvar _ lifecycle.Component = (*MyClient)(nil)\nvar _ observability.Checkable = (*MyClient)(nil)\n\nfunc (c *MyClient) OnInit() error { /* open connection */ }\nfunc (c *MyClient) OnStart() error { return nil }\nfunc (c *MyClient) OnStop() error { /* close connection */ }\nfunc (c *MyClient) HealthCheck(ctx context.Context) error { /* ping */ }\nfunc (c *MyClient) Name() string { return \"my-client\" }\nfunc (c *MyClient) Priority() observability.Level { return observability.LevelCritical }\n```\n\n### Implementing structured errors\n\n```go\n// MyErr satisfies errs.CodedError and errs.ContextualError.\nvar _ errs.CodedError = (*MyErr)(nil)\nvar _ errs.ContextualError = (*MyErr)(nil)\n\nfunc (e *MyErr) ErrorCode() string { return e.code }\nfunc (e *MyErr) ErrorContext() map[string]any { return e.ctx }\n```\n\nThe logger in `core` consumes these interfaces to append `error_code` and context\nfields to every log record automatically — without either package importing the other.\n\n### Working with identity and permissions\n\n```go\n// Store identity after authentication.\nctx = security.SetInContext(ctx, security.NewIdentity(uid, name, email))\n\n// Retrieve identity downstream.\nid, ok := security.FromContext(ctx)\n\n// Resolve and check permissions.\nmask, err := provider.ResolveMask(ctx, id.UID, \"orders\")\nif !mask.Has(PermWrite) { /* deny */ }\n```\n\n---\n\n## Dependency Rules\n\n`contracts` sits at the root of the Einherjar dependency graph:\n\n```\ncontracts\n └── core (absorbs launcher, logz, xerrors, valid)\n ├── web (absorbs httpserver, httpmw, httputil, health)\n │ └── auth\n │ ├── auth-jwt\n │ └── auth-firebase\n └── db-*, cache-*, storage-*, telemetry, worker, httpclient\n```\n\n**Changes flow outward from `contracts`, never inward.** A starter never drives a\ncontracts change. Before any modification, calculate the blast radius: which interfaces\nare affected → which starters implement them → which starters consume those starters.\nRelease sequence is always contracts first, then implementors, then consumers.\n\n---\n\n## Compliance\n\n```bash\ngo build ./... # zero external dependencies\ngo vet ./...\ngo test ./... # structural (one type per file) + behavioural tests\ngofmt -l . # no output\n```\n\n---\n\n*The Einherjar did not train for themselves. They trained for Ragnarök — for the battle they knew was coming, for those who would need them to be ready. Write code the same way.*\n",
|
||
"changelog": "# Changelog\n\nAll notable changes to this module will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code, API, or dependency changes.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code, API, or dependency changes.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment. No code, API, or dependency changes.\n\n## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment. No code, API, or dependency changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — coordinated framework version alignment. Released in lockstep at v1.1.1\nalongside a documentation patch in the data/infra starters. No code, API, or dependency changes.\n\n## [1.1.0] — 2026-08-07\n\n### Added\n\n**`security`**\n\n- `SecurityBag` struct — request-scoped security context that carries both the\n authenticated `Identity` and arbitrary string-keyed attributes injected during\n the enrichment phase (e.g. hardware IDs, grant codes). Value type — all mutation\n methods return a new value without modifying the receiver. The attribute map is\n copied on every `With` call to preserve immutability guarantees.\n- `NewSecurityBag(id Identity) SecurityBag` — constructs a `SecurityBag` wrapping\n the given Identity with an empty attribute map\n- `SecurityBag.Identity() Identity` — returns the authenticated Identity stored in\n the bag\n- `SecurityBag.WithIdentity(id Identity) SecurityBag` — returns a copy of the bag\n with the Identity replaced; used by bag enrichers that modify the Identity (e.g.\n applying a tenant ID from a request header)\n- `SecurityBag.Get(key string) (any, bool)` — returns the attribute stored under\n key, or `(nil, false)` if absent\n- `SecurityBag.With(key string, value any) SecurityBag` — returns a copy of the\n bag with the given key set to value; the receiver is not modified\n- `SetBagInContext(ctx context.Context, bag SecurityBag) context.Context` — stores\n the full `SecurityBag` in ctx; use instead of `SetInContext` when extra attributes\n need to travel alongside the Identity\n- `BagFromContext(ctx context.Context) (SecurityBag, bool)` — retrieves the\n `SecurityBag` stored by `SetBagInContext` or `SetInContext`; permission providers\n use this to access both the Identity and any enrichment attributes\n\n### Changed\n\n**`security`**\n\n- `SetInContext` — now stores a `SecurityBag` wrapping the Identity internally,\n replacing direct `Identity` storage. The function signature is **unchanged**;\n all existing callers continue to work without modification.\n- `FromContext` — now extracts the Identity from the stored `SecurityBag`\n internally. The function signature is **unchanged**; all existing callers\n continue to work without modification. `SetBagInContext` + `FromContext` is\n valid (returns the bag's Identity). `SetInContext` + `BagFromContext` is valid\n (returns a bag wrapping the identity with an empty attribute map).\n\n### Design Notes\n\n- `SecurityBag` is additive — no existing interface or function signature is\n modified. Starters compiled against v1.0.0 continue to work against v1.1.0\n without any code changes.\n- The framework defines no string key constants for bag attributes. All\n framework-known fields are typed fields on `Identity`. Callers define their\n own constants to avoid collisions and maintain compile-time safety at the\n type-assertion call site.\n\n---\n\n## [1.0.0] — 2026-05-27\n\n### Added\n\n**`lifecycle`**\n\n- `Component` interface — `OnInit() error` (open connections, allocate resources),\n `OnStart() error` (start background goroutines and listeners), `OnStop() error`\n (graceful shutdown and resource release); the framework calls these in strict\n phase order and shuts down components in reverse registration order\n\n**`observability`**\n\n- `Level` type — `int`-based criticality classifier for health reporting; zero value\n is `LevelCritical`, making it a safe default for any component that forgets to set it\n- `LevelCritical` constant — marks a component essential to application function;\n a failing critical component sets overall health to DOWN (HTTP 503)\n- `LevelDegraded` constant — marks a component important but not essential; a failing\n degraded component sets overall health to DEGRADED (HTTP 200), not DOWN\n- `Checkable` interface — `HealthCheck(ctx context.Context) error` (connectivity or\n liveness probe), `Name() string` (display name in health responses), `Priority() Level`\n (criticality of this component); implemented by all infrastructure components;\n consumed by the `web` starter's health handler without the data layer ever importing HTTP\n\n**`logging`**\n\n- `Logger` interface — `Debug`, `Info`, `Warn(msg string, args ...any)`, `Error(msg string, err error, args ...any)`,\n `With(args ...any) Logger`, `WithContext(ctx context.Context) Logger`; all Einherjar\n starters accept `Logger` at their constructors and pass it to sub-components — never\n the concrete implementation; `Error` treats `err` as a first-class parameter to enable\n automatic enrichment from `errs.CodedError` and `errs.ContextualError`\n\n**`errs`**\n\n- `CodedError` interface — `ErrorCode() string`; implemented by structured errors that\n carry a machine-readable SCREAMING_SNAKE_CASE code meaningful to frontend consumers;\n consumed by the `core` logger to append `error_code` automatically to every log record\n where the error satisfies this interface; internal or infrastructure errors must not\n carry a code — those get a generic fallback on the frontend\n- `ContextualError` interface — `ErrorContext() map[string]any`; implemented by\n structured errors that carry key-value context fields; consumed by the `core` logger\n to append all context fields to the log record automatically; the returned map must\n not be modified by the caller\n\n**`security`**\n\n- `Identity` struct — `UID`, `TenantID`, `DisplayName`, `Email string`; value type,\n always copied never a pointer, preventing nil-check burden and accidental mutation\n across concurrent middleware\n- `NewIdentity(uid, displayName, email string) Identity` — constructs an Identity from\n token authentication data; `TenantID` is intentionally left empty for enrichment\n in a later middleware step\n- `Identity.WithTenant(id string) Identity` — returns a copy of the Identity with\n `TenantID` set; the receiver is not mutated, safe to call from concurrent middleware\n- `SetInContext(ctx context.Context, id Identity) context.Context` — stores an Identity\n in the context using a package-private key type, preventing collisions with keys from\n other packages\n- `FromContext(ctx context.Context) (Identity, bool)` — retrieves the Identity stored by\n `SetInContext`; returns the zero-value Identity and false if no identity is present\n- `Permission` type — named `int64` bit position (0–62) representing a single\n capability; applications define their own domain constants using this type\n- `MaxPermission` constant — highest valid bit position (62); bit 63 is reserved for\n the sign bit of the underlying `int64`\n- `PermissionMask` type — resolved `int64` bit-mask for a user on a specific resource;\n zero value means no permissions granted\n- `PermissionMask.Has(p Permission) bool` — reports whether the given permission bit\n is set; returns false for out-of-range values (p \u003c 0 or p \u003e MaxPermission)\n- `PermissionMask.Grant(p Permission) PermissionMask` — returns a new mask with the\n bit for p set; the receiver is not modified, safe to use in builder chains\n- `PermissionProvider` interface — `ResolveMask(ctx context.Context, uid, resource string) (PermissionMask, error)`;\n implementations may call `FromContext` to retrieve the Identity and its TenantID\n when multi-tenancy is required\n\n### Design Notes\n\n- `contracts` has zero external dependencies and zero Einherjar dependencies. Its\n `go.mod` requires nothing beyond the Go standard library. If adding something to\n `contracts` requires a new `require` entry, it does not belong in `contracts`.\n\n- Changes flow outward from `contracts`, never inward. A starter never drives a\n contracts change. Before any modification, the blast radius is calculated: which\n interfaces are affected → which starters implement them → which starters consume\n those starters. Release sequence: `contracts` first, then implementors, then\n consumers.\n\n- The `errs` sub-package formalises a contract that previously existed only as\n private duck-typed interfaces inside micro-lib's `logz`. The two interfaces are\n intentionally separate (`CodedError` and `ContextualError` rather than one combined\n `RichError`) to honour ISP: an error may carry a code but no context fields, or\n context fields but no code. Implementors are never forced to provide both.\n\n- `Checkable` lives in `contracts/observability`, not in the `web` starter. Data\n starters (`db-postgres`, `db-sqlite`, etc.) implement `Checkable` without importing\n the HTTP layer. The health handler in `web` consumes `Checkable` — the dependency\n arrow points into the data layer, never out.\n\n- Every file in `contracts` exports exactly one type or interface. This is enforced\n structurally and mechanically by the compliance test using `go/ast` parsing. A\n reviewer reading `permission_mask.go` knows exactly what they are reading without\n opening any other file.\n\n[1.0.0]: https://code.nochebuena.dev/einherjar/contracts/releases/tag/v1.0.0\n"
|
||
},
|
||
{
|
||
"name": "core",
|
||
"importPath": "code.nochebuena.dev/einherjar/core",
|
||
"purpose": "The chosen warriors do not choose their weapons. They forge them.",
|
||
"doc": "Package core provides the foundational implementations of the Einherjar\nframework contracts: lifecycle management, structured logging, structured\nerrors, and struct validation.\n\nSub-packages:\n - launcher — application lifecycle orchestration (init → start → shutdown)\n - logz — structured, leveled logging backed by log/slog\n - xerrors — typed error codes with context enrichment\n - valid — struct validation with pluggable i18n messages",
|
||
"goVersion": "1.26",
|
||
"dependsOn": [
|
||
"contracts"
|
||
],
|
||
"subPackages": [
|
||
{
|
||
"name": "",
|
||
"importPath": "code.nochebuena.dev/einherjar/core",
|
||
"doc": "Package core provides the foundational implementations of the Einherjar\nframework contracts: lifecycle management, structured logging, structured\nerrors, and struct validation.\n\nSub-packages:\n - launcher — application lifecycle orchestration (init → start → shutdown)\n - logz — structured, leveled logging backed by log/slog\n - xerrors — typed error codes with context enrichment\n - valid — struct validation with pluggable i18n messages"
|
||
},
|
||
{
|
||
"name": "launcher",
|
||
"importPath": "code.nochebuena.dev/einherjar/core/launcher",
|
||
"doc": "Package launcher orchestrates the application lifecycle.\n\nA Launcher manages infrastructure components through three ordered phases:\n\n 1. OnInit — all components initialize in registration order\n 2. BeforeStart hooks — dependency injection wiring runs\n 3. OnStart — all components start in registration order\n\nOn shutdown (OS signal or programmatic Shutdown call), OnStop is called for\nevery component in reverse registration order, ensuring dependents stop\nbefore their dependencies.\n\nUsage:\n\n\tlogger := logz.New(logz.Config{JSON: true})\n\tlc := launcher.New(logger)\n\n\tlc.Append(db, cache, server)\n\tlc.BeforeStart(func() error {\n\t return server.RegisterRoutes(db, cache)\n\t})\n\n\tif err := lc.Run(); err != nil {\n\t logger.Error(\"launcher failed\", err)\n\t os.Exit(1)\n\t}"
|
||
},
|
||
{
|
||
"name": "logz",
|
||
"importPath": "code.nochebuena.dev/einherjar/core/logz",
|
||
"doc": "Package logz provides a structured, leveled logger backed by the standard\nlibrary's log/slog package.\n\nlogz.New returns a logging.Logger (from contracts). No concrete type is\nexported — callers depend on the interface, not the implementation.\n\nError enrichment: when an error passed to Logger.Error implements\nerrs.CodedError or errs.ContextualError (from contracts/errs), the\ncorresponding fields are automatically appended to the log record without\neither package importing the other.\n\nContext enrichment: WithRequestID, WithField, and WithFields store values\nin context.Context. Logger.WithContext reads them back and attaches\nrequest_id and any extra fields to every subsequent log record.\n\nUsage:\n\n\tlogger := logz.New(logz.Config{\n\t Level: slog.LevelDebug,\n\t JSON: true,\n\t StaticArgs: []any{\"service\", \"api\"},\n\t})\n\n\tctx = logz.WithRequestID(ctx, requestID)\n\treqLogger := logger.WithContext(ctx)\n\treqLogger.Info(\"handling request\", \"path\", r.URL.Path)"
|
||
},
|
||
{
|
||
"name": "valid",
|
||
"importPath": "code.nochebuena.dev/einherjar/core/valid",
|
||
"doc": "Package valid wraps github.com/go-playground/validator/v10 behind a minimal\nValidator interface, returning xerrors-typed errors with stable codes.\n\nThe backend library is fully hidden — callers interact only with Validator\nand *xerrors.Err. Swapping the backend in the future requires no API changes.\n\nUsage:\n\n\tv := valid.New()\n\tif err := v.Struct(req); err != nil {\n\t // err is *xerrors.Err with Code() == xerrors.ErrInvalidInput\n\t // err.Fields()[\"field\"] contains the failing field name\n\t}\n\n\t// Spanish messages\n\tv := valid.New(valid.WithMessageProvider(valid.SpanishMessages))\n\nStruct tags follow go-playground/validator conventions:\n\n\ttype CreateUserReq struct {\n\t Email string `json:\"email\" validate:\"required,email\"`\n\t Age int `json:\"age\" validate:\"min=18\"`\n\t}"
|
||
},
|
||
{
|
||
"name": "xerrors",
|
||
"importPath": "code.nochebuena.dev/einherjar/core/xerrors",
|
||
"doc": "Package xerrors provides structured application errors with stable typed codes,\ncause chaining, and key-value context fields.\n\nEvery error carries a machine-readable Code (gRPC-aligned wire value), a\nhuman-readable message, an optional cause, and optional structured fields.\n*Err implements errs.CodedError and errs.ContextualError from contracts,\nenabling logz to enrich log records automatically without importing this package.\n\nUsage:\n\n\t// Named constructors for common codes\n\terr := xerrors.NotFound(\"user %s not found\", userID)\n\terr := xerrors.InvalidInput(\"email is required\")\n\n\t// Builder pattern for structured context\n\terr := xerrors.New(xerrors.ErrInvalidInput, \"validation failed\").\n\t WithContext(\"field\", \"email\").\n\t WithContext(\"rule\", \"required\").\n\t WithError(cause)\n\n\t// Inspecting errors\n\tvar e *xerrors.Err\n\tif errors.As(err, \u0026e) {\n\t switch e.Code() {\n\t case xerrors.ErrNotFound:\n\t // handle 404\n\t }\n\t}"
|
||
}
|
||
],
|
||
"symbols": [
|
||
{
|
||
"module": "core",
|
||
"subPackage": "launcher",
|
||
"kind": "type",
|
||
"name": "Config",
|
||
"signature": "type Config struct",
|
||
"doc": "Config configures a Launcher instance.\nThe zero value is valid: 15-second component stop timeout.",
|
||
"file": "launcher/config.go",
|
||
"line": 7,
|
||
"fields": [
|
||
{
|
||
"name": "ComponentStopTimeout",
|
||
"type": "time.Duration",
|
||
"tag": "env:\"EINHERJAR_COMPONENT_STOP_TIMEOUT\" envDefault:\"15s\"",
|
||
"doc": "ComponentStopTimeout is the maximum time allowed for each component's OnStop.\nDefault: 15 seconds."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "launcher",
|
||
"kind": "type",
|
||
"name": "Hook",
|
||
"signature": "type Hook func() error",
|
||
"doc": "Hook is a function executed during the assembly phase — after all OnInit calls\nand before all OnStart calls. Use hooks for dependency injection wiring that\nrequires every component to be initialized before connections are established.",
|
||
"file": "launcher/hook.go",
|
||
"line": 6
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "launcher",
|
||
"kind": "interface",
|
||
"name": "Launcher",
|
||
"signature": "type Launcher interface",
|
||
"doc": "Launcher manages the application lifecycle: init → assemble → start → wait → shutdown.",
|
||
"file": "launcher/launcher.go",
|
||
"line": 17,
|
||
"methods": [
|
||
{
|
||
"name": "Append",
|
||
"signature": "Append(components ...lifecycle.Component)",
|
||
"doc": "Append adds one or more components. Registered in the order they are appended;\nshutdown runs in reverse order."
|
||
},
|
||
{
|
||
"name": "BeforeStart",
|
||
"signature": "BeforeStart(hooks ...Hook)",
|
||
"doc": "BeforeStart registers hooks that run after all OnInit calls and before all OnStart\ncalls. Use for dependency injection wiring."
|
||
},
|
||
{
|
||
"name": "Run",
|
||
"signature": "Run() error",
|
||
"doc": "Run executes the full application lifecycle. Blocks until an OS shutdown signal is\nreceived or Shutdown is called. Returns an error if any lifecycle step fails.\nThe caller is responsible for calling os.Exit(1) when needed."
|
||
},
|
||
{
|
||
"name": "Shutdown",
|
||
"signature": "Shutdown(ctx context.Context) error",
|
||
"doc": "Shutdown triggers a graceful shutdown and waits for Run to return.\nctx controls the caller-side wait timeout — it does NOT override\nConfig.ComponentStopTimeout for individual components.\nSafe to call multiple times (idempotent)."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "launcher",
|
||
"kind": "func",
|
||
"name": "New",
|
||
"signature": "func New(logger logging.Logger, opts ...Config) Launcher",
|
||
"doc": "New returns a Launcher configured by opts. The zero value of Config is valid.",
|
||
"file": "launcher/launcher.go",
|
||
"line": 38
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "launcher",
|
||
"kind": "type",
|
||
"name": "launcher",
|
||
"signature": "type launcher struct",
|
||
"doc": "",
|
||
"file": "launcher/launcher.go",
|
||
"line": 54,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
},
|
||
{
|
||
"name": "opts",
|
||
"type": "Config"
|
||
},
|
||
{
|
||
"name": "components",
|
||
"type": "[]lifecycle.Component"
|
||
},
|
||
{
|
||
"name": "beforeStart",
|
||
"type": "[]Hook"
|
||
},
|
||
{
|
||
"name": "shutdownCh",
|
||
"type": "chan struct{}"
|
||
},
|
||
{
|
||
"name": "doneCh",
|
||
"type": "chan struct{}"
|
||
},
|
||
{
|
||
"name": "shutdownOnce",
|
||
"type": "sync.Once"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "launcher",
|
||
"kind": "method",
|
||
"name": "launcher.Append",
|
||
"signature": "func (l *launcher) Append(components ...lifecycle.Component)",
|
||
"doc": "",
|
||
"file": "launcher/launcher.go",
|
||
"line": 64
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "launcher",
|
||
"kind": "method",
|
||
"name": "launcher.BeforeStart",
|
||
"signature": "func (l *launcher) BeforeStart(hooks ...Hook)",
|
||
"doc": "",
|
||
"file": "launcher/launcher.go",
|
||
"line": 68
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "launcher",
|
||
"kind": "method",
|
||
"name": "launcher.Run",
|
||
"signature": "func (l *launcher) Run() error",
|
||
"doc": "Run executes the full application lifecycle:\n 1. Prints the startup banner (unless EINHERJAR_BANNER=off).\n 2. OnInit for all components (in registration order).\n 3. BeforeStart hooks (in registration order).\n 4. OnStart for all components (in registration order).\n 5. Blocks until an OS signal or Shutdown() is called.\n 6. stopAll — OnStop for all components (in reverse order).",
|
||
"file": "launcher/launcher.go",
|
||
"line": 79
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "launcher",
|
||
"kind": "method",
|
||
"name": "launcher.Shutdown",
|
||
"signature": "func (l *launcher) Shutdown(ctx context.Context) error",
|
||
"doc": "",
|
||
"file": "launcher/launcher.go",
|
||
"line": 129
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "launcher",
|
||
"kind": "method",
|
||
"name": "launcher.stopAll",
|
||
"signature": "func (l *launcher) stopAll()",
|
||
"doc": "",
|
||
"file": "launcher/launcher.go",
|
||
"line": 142
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "launcher",
|
||
"kind": "func",
|
||
"name": "coreVersion",
|
||
"signature": "func coreVersion() string",
|
||
"doc": "",
|
||
"file": "launcher/banner.go",
|
||
"line": 27
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "launcher",
|
||
"kind": "func",
|
||
"name": "printBanner",
|
||
"signature": "func printBanner(identifiables []observability.Identifiable)",
|
||
"doc": "",
|
||
"file": "launcher/banner.go",
|
||
"line": 12
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "launcher",
|
||
"kind": "const",
|
||
"name": "bannerText",
|
||
"signature": "const bannerText = `\n ███████╗██╗███╗ ██╗██╗ ██╗███████╗██████╗ ██╗ █████╗ ██████╗\n ██╔════╝██║████╗ ██║██║ ██║██╔════╝██╔══██╗ ██║██╔══██╗██╔══██╗\n █████╗ ██║██╔██╗ ██║███████║█████╗ ██████╔╝ ██║███████║██████╔╝\n ██╔══╝ ██║██║╚██╗██║██╔══██║██╔══╝ ██╔══██╗██ ██║██╔══██║██╔══██╗\n ███████╗██║██║ ╚████║██║ ██║███████╗██║ ██║╚█████╔╝██║ ██║██║ ██║\n ╚══════╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚════╝ ╚═╝ ╚═╝╚═╝ ╚═╝\n code.nochebuena.dev/einherjar · Chosen warriors. Not for themselves. · %s\n\n`",
|
||
"doc": "",
|
||
"file": "launcher/banner.go",
|
||
"line": 42
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "launcher",
|
||
"kind": "const",
|
||
"name": "defaultComponentStopTimeout",
|
||
"signature": "const defaultComponentStopTimeout = 15 * time.Second",
|
||
"doc": "",
|
||
"file": "launcher/config.go",
|
||
"line": 13
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "logz",
|
||
"kind": "type",
|
||
"name": "Config",
|
||
"signature": "type Config struct",
|
||
"doc": "Config configures a Logger instance.\nThe zero value is valid: INFO level, text output, os.Stdout, no static args.",
|
||
"file": "logz/config.go",
|
||
"line": 10,
|
||
"fields": [
|
||
{
|
||
"name": "Level",
|
||
"type": "slog.Level",
|
||
"tag": "env:\"EINHERJAR_LOG_LEVEL\" envDefault:\"INFO\"",
|
||
"doc": "Level is the minimum log level. Default: slog.LevelInfo (zero value)."
|
||
},
|
||
{
|
||
"name": "JSON",
|
||
"type": "bool",
|
||
"tag": "env:\"EINHERJAR_LOG_JSON\" envDefault:\"false\"",
|
||
"doc": "JSON enables JSON output. Default: false (text output)."
|
||
},
|
||
{
|
||
"name": "StaticArgs",
|
||
"type": "[]any",
|
||
"doc": "StaticArgs are key-value pairs attached to every log record."
|
||
},
|
||
{
|
||
"name": "Writer",
|
||
"type": "io.Writer",
|
||
"doc": "Writer is the output destination. Defaults to os.Stdout when nil.\nAccepts any io.Writer: *os.File, bytes.Buffer, io.MultiWriter, etc."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "logz",
|
||
"kind": "type",
|
||
"name": "ctxExtraFieldsKey",
|
||
"signature": "type ctxExtraFieldsKey struct",
|
||
"doc": "",
|
||
"file": "logz/context.go",
|
||
"line": 6
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "logz",
|
||
"kind": "type",
|
||
"name": "ctxRequestIDKey",
|
||
"signature": "type ctxRequestIDKey struct",
|
||
"doc": "",
|
||
"file": "logz/context.go",
|
||
"line": 5
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "logz",
|
||
"kind": "type",
|
||
"name": "slogLogger",
|
||
"signature": "type slogLogger struct",
|
||
"doc": "",
|
||
"file": "logz/logger.go",
|
||
"line": 38,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "*slog.Logger"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "logz",
|
||
"kind": "method",
|
||
"name": "slogLogger.Debug",
|
||
"signature": "func (l *slogLogger) Debug(msg string, args ...any)",
|
||
"doc": "",
|
||
"file": "logz/logger.go",
|
||
"line": 42
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "logz",
|
||
"kind": "method",
|
||
"name": "slogLogger.Error",
|
||
"signature": "func (l *slogLogger) Error(msg string, err error, args ...any)",
|
||
"doc": "",
|
||
"file": "logz/logger.go",
|
||
"line": 46
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "logz",
|
||
"kind": "method",
|
||
"name": "slogLogger.Info",
|
||
"signature": "func (l *slogLogger) Info(msg string, args ...any)",
|
||
"doc": "",
|
||
"file": "logz/logger.go",
|
||
"line": 43
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "logz",
|
||
"kind": "method",
|
||
"name": "slogLogger.Warn",
|
||
"signature": "func (l *slogLogger) Warn(msg string, args ...any)",
|
||
"doc": "",
|
||
"file": "logz/logger.go",
|
||
"line": 44
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "logz",
|
||
"kind": "method",
|
||
"name": "slogLogger.With",
|
||
"signature": "func (l *slogLogger) With(args ...any) logging.Logger",
|
||
"doc": "",
|
||
"file": "logz/logger.go",
|
||
"line": 54
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "logz",
|
||
"kind": "method",
|
||
"name": "slogLogger.WithContext",
|
||
"signature": "func (l *slogLogger) WithContext(ctx context.Context) logging.Logger",
|
||
"doc": "",
|
||
"file": "logz/logger.go",
|
||
"line": 58
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "logz",
|
||
"kind": "func",
|
||
"name": "GetRequestID",
|
||
"signature": "func GetRequestID(ctx context.Context) string",
|
||
"doc": "GetRequestID retrieves the correlation ID from the context.\nReturns \"\" if not present or if ctx is nil.",
|
||
"file": "logz/context.go",
|
||
"line": 15
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "logz",
|
||
"kind": "func",
|
||
"name": "New",
|
||
"signature": "func New(cfg Config) logging.Logger",
|
||
"doc": "New returns a Logger configured by cfg.",
|
||
"file": "logz/logger.go",
|
||
"line": 16
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "logz",
|
||
"kind": "func",
|
||
"name": "WithField",
|
||
"signature": "func WithField(ctx context.Context, key string, value any) context.Context",
|
||
"doc": "WithField adds a single key-value pair to the context for logging.",
|
||
"file": "logz/context.go",
|
||
"line": 24
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "logz",
|
||
"kind": "func",
|
||
"name": "WithFields",
|
||
"signature": "func WithFields(ctx context.Context, fields map[string]any) context.Context",
|
||
"doc": "WithFields adds multiple key-value pairs to the context for logging.\nMerges with any existing fields — does not overwrite the whole map.",
|
||
"file": "logz/context.go",
|
||
"line": 30
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "logz",
|
||
"kind": "func",
|
||
"name": "WithRequestID",
|
||
"signature": "func WithRequestID(ctx context.Context, id string) context.Context",
|
||
"doc": "WithRequestID adds a request correlation ID to the context.",
|
||
"file": "logz/context.go",
|
||
"line": 9
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "logz",
|
||
"kind": "func",
|
||
"name": "enrichErrorAttrs",
|
||
"signature": "func enrichErrorAttrs(err error, attrs []any) []any",
|
||
"doc": "enrichErrorAttrs appends error_code and context fields from err when err\nsatisfies errs.CodedError or errs.ContextualError from contracts.\nReturns attrs unchanged if err is nil or satisfies neither interface.",
|
||
"file": "logz/logger.go",
|
||
"line": 88
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "logz",
|
||
"kind": "var",
|
||
"name": "_",
|
||
"signature": "var _ logging.Logger = (*slogLogger)(nil)",
|
||
"doc": "",
|
||
"file": "logz/logger.go",
|
||
"line": 13
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "interface",
|
||
"name": "FieldLevel",
|
||
"signature": "type FieldLevel interface",
|
||
"doc": "FieldLevel provides access to the field being validated.\nIt is passed to custom validator functions registered via WithCustomValidator.",
|
||
"file": "valid/field_level.go",
|
||
"line": 7,
|
||
"methods": [
|
||
{
|
||
"name": "Field",
|
||
"signature": "Field() reflect.Value",
|
||
"doc": "Field returns the reflect.Value of the field being validated."
|
||
},
|
||
{
|
||
"name": "Param",
|
||
"signature": "Param() string",
|
||
"doc": "Param returns the tag parameter, if any.\nFor validate:\"mytag=value\", Param() returns \"value\"; otherwise \"\"."
|
||
},
|
||
{
|
||
"name": "FieldName",
|
||
"signature": "FieldName() string",
|
||
"doc": "FieldName returns the field name used for error messages (json tag or Go name)."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "interface",
|
||
"name": "MessageProvider",
|
||
"signature": "type MessageProvider interface",
|
||
"doc": "MessageProvider maps a validation failure to a human-readable message.\n\n - field: the struct field name (e.g. \"Email\")\n - tag: the failing validation rule (e.g. \"required\", \"email\", \"min\")\n - param: the rule parameter if any (e.g. \"18\" for min=18), or \"\" if none",
|
||
"file": "valid/message_provider.go",
|
||
"line": 10,
|
||
"methods": [
|
||
{
|
||
"name": "Message",
|
||
"signature": "Message(field, tag, param string) string"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "func",
|
||
"name": "OverrideProvider",
|
||
"signature": "func OverrideProvider(handlers map[string]func(field, param string) string, base MessageProvider) MessageProvider",
|
||
"doc": "OverrideProvider returns a MessageProvider that resolves messages from handlers\nfor specific tags, delegating to base for any tag not in handlers.\n\nUse it to supply messages for custom validators or to override individual tags:\n\n\tp := valid.OverrideProvider(\n\t map[string]func(field, param string) string{\n\t \"strongpassword\": func(field, _ string) string {\n\t return fmt.Sprintf(\"field '%s' must contain a letter, digit, and symbol\", field)\n\t },\n\t },\n\t valid.DefaultMessages,\n\t)",
|
||
"file": "valid/message_provider.go",
|
||
"line": 34
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "type",
|
||
"name": "Option",
|
||
"signature": "type Option func(*config)",
|
||
"doc": "Option configures a Validator.",
|
||
"file": "valid/option.go",
|
||
"line": 4
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "func",
|
||
"name": "WithCustomValidator",
|
||
"signature": "func WithCustomValidator(tag string, fn func(FieldLevel) bool) Option",
|
||
"doc": "WithCustomValidator registers a custom validation tag and its function.\nThe tag can then be used in struct tags: validate:\"mytag\" or validate:\"mytag=param\".\nPanics if registration fails (empty tag or tag that conflicts with a built-in).",
|
||
"file": "valid/option.go",
|
||
"line": 17
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "func",
|
||
"name": "WithMessageProvider",
|
||
"signature": "func WithMessageProvider(mp MessageProvider) Option",
|
||
"doc": "WithMessageProvider sets a custom MessageProvider.\nDefault: DefaultMessages (English).",
|
||
"file": "valid/option.go",
|
||
"line": 8
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "interface",
|
||
"name": "Validator",
|
||
"signature": "type Validator interface",
|
||
"doc": "Validator validates structs using struct tags.",
|
||
"file": "valid/validator.go",
|
||
"line": 13,
|
||
"methods": [
|
||
{
|
||
"name": "Struct",
|
||
"signature": "Struct(v any) error",
|
||
"doc": "Struct validates v and returns a *xerrors.Err if validation fails.\nReturns nil if v is valid.\nReturns ErrInvalidInput for field constraint failures (first error only).\nReturns ErrInternal if v is not a struct."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "func",
|
||
"name": "New",
|
||
"signature": "func New(opts ...Option) Validator",
|
||
"doc": "New returns a Validator. Without options, DefaultMessages (English) is used.\nField names in error context use the json struct tag when available,\nfalling back to the Go field name.",
|
||
"file": "valid/validator.go",
|
||
"line": 26
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "type",
|
||
"name": "config",
|
||
"signature": "type config struct",
|
||
"doc": "",
|
||
"file": "valid/option.go",
|
||
"line": 28,
|
||
"fields": [
|
||
{
|
||
"name": "mp",
|
||
"type": "MessageProvider"
|
||
},
|
||
{
|
||
"name": "customValidators",
|
||
"type": "[]customValidator"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "type",
|
||
"name": "customValidator",
|
||
"signature": "type customValidator struct",
|
||
"doc": "",
|
||
"file": "valid/option.go",
|
||
"line": 23,
|
||
"fields": [
|
||
{
|
||
"name": "tag",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"name": "fn",
|
||
"type": "func(FieldLevel) bool"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "type",
|
||
"name": "defaultMessages",
|
||
"signature": "type defaultMessages struct",
|
||
"doc": "",
|
||
"file": "valid/message_provider.go",
|
||
"line": 54
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "method",
|
||
"name": "defaultMessages.Message",
|
||
"signature": "func (defaultMessages) Message(field, tag, param string) string",
|
||
"doc": "",
|
||
"file": "valid/message_provider.go",
|
||
"line": 56
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "type",
|
||
"name": "fieldLevelAdapter",
|
||
"signature": "type fieldLevelAdapter struct",
|
||
"doc": "",
|
||
"file": "valid/validator.go",
|
||
"line": 49,
|
||
"fields": [
|
||
{
|
||
"name": "fl",
|
||
"type": "playground.FieldLevel"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "method",
|
||
"name": "fieldLevelAdapter.Field",
|
||
"signature": "func (a fieldLevelAdapter) Field() reflect.Value",
|
||
"doc": "",
|
||
"file": "valid/validator.go",
|
||
"line": 51
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "method",
|
||
"name": "fieldLevelAdapter.FieldName",
|
||
"signature": "func (a fieldLevelAdapter) FieldName() string",
|
||
"doc": "",
|
||
"file": "valid/validator.go",
|
||
"line": 53
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "method",
|
||
"name": "fieldLevelAdapter.Param",
|
||
"signature": "func (a fieldLevelAdapter) Param() string",
|
||
"doc": "",
|
||
"file": "valid/validator.go",
|
||
"line": 52
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "type",
|
||
"name": "overrideProvider",
|
||
"signature": "type overrideProvider struct",
|
||
"doc": "",
|
||
"file": "valid/message_provider.go",
|
||
"line": 38,
|
||
"fields": [
|
||
{
|
||
"name": "handlers",
|
||
"type": "map[string]func(field, param string) string"
|
||
},
|
||
{
|
||
"name": "base",
|
||
"type": "MessageProvider"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "method",
|
||
"name": "overrideProvider.Message",
|
||
"signature": "func (p *overrideProvider) Message(field, tag, param string) string",
|
||
"doc": "",
|
||
"file": "valid/message_provider.go",
|
||
"line": 43
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "type",
|
||
"name": "spanishMessages",
|
||
"signature": "type spanishMessages struct",
|
||
"doc": "",
|
||
"file": "valid/message_provider.go",
|
||
"line": 405
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "method",
|
||
"name": "spanishMessages.Message",
|
||
"signature": "func (spanishMessages) Message(field, tag, param string) string",
|
||
"doc": "",
|
||
"file": "valid/message_provider.go",
|
||
"line": 407
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "type",
|
||
"name": "validator",
|
||
"signature": "type validator struct",
|
||
"doc": "",
|
||
"file": "valid/validator.go",
|
||
"line": 55,
|
||
"fields": [
|
||
{
|
||
"name": "v",
|
||
"type": "*playground.Validate"
|
||
},
|
||
{
|
||
"name": "mp",
|
||
"type": "MessageProvider"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "valid",
|
||
"kind": "method",
|
||
"name": "validator.Struct",
|
||
"signature": "func (val *validator) Struct(v any) error",
|
||
"doc": "Struct implements Validator.\nOnly the first validation error is surfaced. Apps needing all failures can\ncast errors.Unwrap(err) to playground.ValidationErrors themselves.",
|
||
"file": "valid/validator.go",
|
||
"line": 63
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "type",
|
||
"name": "Code",
|
||
"signature": "type Code string",
|
||
"doc": "Code is the machine-readable error category.\nWire values are stable across versions and are identical to gRPC status code\nnames. HTTP mapping is the responsibility of the transport layer, not this package.",
|
||
"file": "xerrors/code.go",
|
||
"line": 6
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "method",
|
||
"name": "Code.Description",
|
||
"signature": "func (c Code) Description() string",
|
||
"doc": "Description returns a human-readable description for the code.\nUnknown codes return their raw string value.",
|
||
"file": "xerrors/code.go",
|
||
"line": 87
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "type",
|
||
"name": "Err",
|
||
"signature": "type Err struct",
|
||
"doc": "Err is a structured application error carrying a Code, a human-readable\nmessage, an optional cause, and optional key-value context fields.\n\nIt implements the standard error interface, errors.Unwrap for cause chaining,\nand json.Marshaler for API responses. It also satisfies errs.CodedError and\nerrs.ContextualError from contracts, enabling logz to enrich log records\nautomatically without importing this package.\n\nUse the builder methods WithContext, WithError, and WithPlatformCode to attach\nadditional information after construction:\n\n\terr := xerrors.New(xerrors.ErrInvalidInput, \"validation failed\").\n\t WithContext(\"field\", \"email\").\n\t WithContext(\"rule\", \"required\").\n\t WithError(cause)",
|
||
"file": "xerrors/err.go",
|
||
"line": 29,
|
||
"fields": [
|
||
{
|
||
"name": "code",
|
||
"type": "Code"
|
||
},
|
||
{
|
||
"name": "message",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"name": "err",
|
||
"type": "error"
|
||
},
|
||
{
|
||
"name": "fields",
|
||
"type": "map[string]any"
|
||
},
|
||
{
|
||
"name": "platformCode",
|
||
"type": "string"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "Aborted",
|
||
"signature": "func Aborted(msg string, args ...any) *Err",
|
||
"doc": "Aborted creates an Err with ErrAborted code.",
|
||
"file": "xerrors/err.go",
|
||
"line": 75
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "AlreadyExists",
|
||
"signature": "func AlreadyExists(msg string, args ...any) *Err",
|
||
"doc": "AlreadyExists creates an Err with ErrAlreadyExists code.",
|
||
"file": "xerrors/err.go",
|
||
"line": 70
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "Cancelled",
|
||
"signature": "func Cancelled(msg string, args ...any) *Err",
|
||
"doc": "Cancelled creates an Err with ErrCancelled code.",
|
||
"file": "xerrors/err.go",
|
||
"line": 89
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "DataLoss",
|
||
"signature": "func DataLoss(msg string, args ...any) *Err",
|
||
"doc": "DataLoss creates an Err with ErrDataLoss code.",
|
||
"file": "xerrors/err.go",
|
||
"line": 95
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "DeadlineExceeded",
|
||
"signature": "func DeadlineExceeded(msg string, args ...any) *Err",
|
||
"doc": "DeadlineExceeded creates an Err with ErrDeadlineExceeded code.",
|
||
"file": "xerrors/err.go",
|
||
"line": 106
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "Gone",
|
||
"signature": "func Gone(msg string, args ...any) *Err",
|
||
"doc": "Gone creates an Err with ErrGone code.",
|
||
"file": "xerrors/err.go",
|
||
"line": 78
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "Internal",
|
||
"signature": "func Internal(msg string, args ...any) *Err",
|
||
"doc": "Internal creates an Err with ErrInternal code.",
|
||
"file": "xerrors/err.go",
|
||
"line": 92
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "InvalidInput",
|
||
"signature": "func InvalidInput(msg string, args ...any) *Err",
|
||
"doc": "InvalidInput creates an Err with ErrInvalidInput code.",
|
||
"file": "xerrors/err.go",
|
||
"line": 49
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "New",
|
||
"signature": "func New(code Code, message string) *Err",
|
||
"doc": "New creates an Err with the given code and message. No cause is set.",
|
||
"file": "xerrors/err.go",
|
||
"line": 38
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "NotFound",
|
||
"signature": "func NotFound(msg string, args ...any) *Err",
|
||
"doc": "NotFound creates an Err with ErrNotFound code.",
|
||
"file": "xerrors/err.go",
|
||
"line": 67
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "NotImplemented",
|
||
"signature": "func NotImplemented(msg string, args ...any) *Err",
|
||
"doc": "NotImplemented creates an Err with ErrNotImplemented code.",
|
||
"file": "xerrors/err.go",
|
||
"line": 98
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "OutOfRange",
|
||
"signature": "func OutOfRange(msg string, args ...any) *Err",
|
||
"doc": "OutOfRange creates an Err with ErrOutOfRange code.",
|
||
"file": "xerrors/err.go",
|
||
"line": 54
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "PermissionDenied",
|
||
"signature": "func PermissionDenied(msg string, args ...any) *Err",
|
||
"doc": "PermissionDenied creates an Err with ErrPermissionDenied code.",
|
||
"file": "xerrors/err.go",
|
||
"line": 62
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "PreconditionFailed",
|
||
"signature": "func PreconditionFailed(msg string, args ...any) *Err",
|
||
"doc": "PreconditionFailed creates an Err with ErrPreconditionFailed code.",
|
||
"file": "xerrors/err.go",
|
||
"line": 81
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "RateLimited",
|
||
"signature": "func RateLimited(msg string, args ...any) *Err",
|
||
"doc": "RateLimited creates an Err with ErrRateLimited code.",
|
||
"file": "xerrors/err.go",
|
||
"line": 86
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "Unauthorized",
|
||
"signature": "func Unauthorized(msg string, args ...any) *Err",
|
||
"doc": "Unauthorized creates an Err with ErrUnauthorized code.",
|
||
"file": "xerrors/err.go",
|
||
"line": 57
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "Unavailable",
|
||
"signature": "func Unavailable(msg string, args ...any) *Err",
|
||
"doc": "Unavailable creates an Err with ErrUnavailable code.",
|
||
"file": "xerrors/err.go",
|
||
"line": 103
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "func",
|
||
"name": "Wrap",
|
||
"signature": "func Wrap(code Code, message string, err error) *Err",
|
||
"doc": "Wrap creates an Err that wraps an existing error with a code and message.\nThe wrapped error is accessible via errors.Is, errors.As, and Err.Unwrap.",
|
||
"file": "xerrors/err.go",
|
||
"line": 44
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "method",
|
||
"name": "Err.Code",
|
||
"signature": "func (e *Err) Code() Code",
|
||
"doc": "Code returns the typed error code.",
|
||
"file": "xerrors/err.go",
|
||
"line": 140
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "method",
|
||
"name": "Err.Detailed",
|
||
"signature": "func (e *Err) Detailed() string",
|
||
"doc": "Detailed returns a verbose string useful for debugging.\nFormat: \"code: X | message: Y | cause: Z | fields: {...}\"",
|
||
"file": "xerrors/err.go",
|
||
"line": 163
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "method",
|
||
"name": "Err.Error",
|
||
"signature": "func (e *Err) Error() string",
|
||
"doc": "Error implements the error interface.\nFormat: \"INVALID_ARGUMENT: username is required → original cause\"",
|
||
"file": "xerrors/err.go",
|
||
"line": 176
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "method",
|
||
"name": "Err.ErrorCode",
|
||
"signature": "func (e *Err) ErrorCode() string",
|
||
"doc": "ErrorCode satisfies errs.CodedError. logz calls this to append error_code\nto log records without importing this package.",
|
||
"file": "xerrors/err.go",
|
||
"line": 190
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "method",
|
||
"name": "Err.ErrorContext",
|
||
"signature": "func (e *Err) ErrorContext() map[string]any",
|
||
"doc": "ErrorContext satisfies errs.ContextualError. logz calls this to append\ncontext fields to log records. The returned map is read-only by logz;\nuse Fields() if you need a safe copy.",
|
||
"file": "xerrors/err.go",
|
||
"line": 195
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "method",
|
||
"name": "Err.Fields",
|
||
"signature": "func (e *Err) Fields() map[string]any",
|
||
"doc": "Fields returns a shallow copy of the context fields.\nReturns an empty (non-nil) map if no fields have been set.",
|
||
"file": "xerrors/err.go",
|
||
"line": 150
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "method",
|
||
"name": "Err.MarshalJSON",
|
||
"signature": "func (e *Err) MarshalJSON() ([]byte, error)",
|
||
"doc": "MarshalJSON implements json.Marshaler.\nOutput: {\"code\":\"NOT_FOUND\",\"platform_code\":\"...\",\"message\":\"...\",\"fields\":{...}}\nplatform_code and fields are omitted when empty.",
|
||
"file": "xerrors/err.go",
|
||
"line": 200
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "method",
|
||
"name": "Err.Message",
|
||
"signature": "func (e *Err) Message() string",
|
||
"doc": "Message returns the human-readable error message.",
|
||
"file": "xerrors/err.go",
|
||
"line": 143
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "method",
|
||
"name": "Err.PlatformCode",
|
||
"signature": "func (e *Err) PlatformCode() string",
|
||
"doc": "PlatformCode returns the platform-level error code, or \"\" if none was set.",
|
||
"file": "xerrors/err.go",
|
||
"line": 146
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "method",
|
||
"name": "Err.Unwrap",
|
||
"signature": "func (e *Err) Unwrap() error",
|
||
"doc": "Unwrap returns the underlying cause, enabling errors.Is and errors.As\nto walk the full cause chain.",
|
||
"file": "xerrors/err.go",
|
||
"line": 186
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "method",
|
||
"name": "Err.WithContext",
|
||
"signature": "func (e *Err) WithContext(key string, value any) *Err",
|
||
"doc": "WithContext adds a key-value pair to the error's context fields and returns\nthe receiver for chaining. Calling it multiple times with the same key\noverwrites the previous value.",
|
||
"file": "xerrors/err.go",
|
||
"line": 113
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "method",
|
||
"name": "Err.WithError",
|
||
"signature": "func (e *Err) WithError(err error) *Err",
|
||
"doc": "WithError sets the underlying cause and returns the receiver for chaining.",
|
||
"file": "xerrors/err.go",
|
||
"line": 122
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "method",
|
||
"name": "Err.WithPlatformCode",
|
||
"signature": "func (e *Err) WithPlatformCode(code string) *Err",
|
||
"doc": "WithPlatformCode sets a platform-level error code and returns the receiver\nfor chaining. Platform codes are domain-specific identifiers (e.g.\n\"EMPLOYEE_NOT_FOUND\") intended for consuming applications — such as a\nfrontend — that need to map errors to localised user-facing messages.\n\nPlatform codes are optional. Errors that have no user-actionable meaning\n(e.g. 500 internal errors) should not carry one.",
|
||
"file": "xerrors/err.go",
|
||
"line": 134
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "var",
|
||
"name": "_",
|
||
"signature": "var _ errs.CodedError = (*Err)(nil)",
|
||
"doc": "Compile-time proof that *Err satisfies the contracts interfaces consumed by logz.",
|
||
"file": "xerrors/err.go",
|
||
"line": 11
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "xerrors",
|
||
"kind": "var",
|
||
"name": "_",
|
||
"signature": "var _ errs.ContextualError = (*Err)(nil)",
|
||
"doc": "",
|
||
"file": "xerrors/err.go",
|
||
"line": 12
|
||
}
|
||
],
|
||
"adrs": [
|
||
{
|
||
"module": "core",
|
||
"id": "ADR-001",
|
||
"title": "ADR-001: core module composition — four sub-packages in one Go module",
|
||
"body": "# ADR-001: core module composition — four sub-packages in one Go module\n\n- **Date:** 2026-05-28\n- **Module:** `code.nochebuena.dev/einherjar/core`\n- **Status:** Accepted\n\n## Context\n\nIn micro-lib, the four packages that become `core` are independent Go modules:\n\n- `code.nochebuena.dev/go/launcher`\n- `code.nochebuena.dev/go/logz`\n- `code.nochebuena.dev/go/xerrors`\n- `code.nochebuena.dev/go/valid`\n\nEach has its own `go.mod`, version tag, and release cycle. A dependency update to\n`xerrors` requires a version bump on `xerrors`, then updating `valid` to consume the\nnew version, then informing all callers of both. Four packages → four coordinated\nversion bumps per change.\n\nIn the Einherjar module structure, each entry in the module inventory is one\nindependently versionable unit. The Spring Boot starter model places the\ndistribution boundary at the starter level (`db-postgres`, `cache-valkey`, `auth`),\nnot at the foundational implementation level.\n\n## Decision\n\n`launcher`, `logz`, `xerrors`, and `valid` are sub-packages of a single\n`code.nochebuena.dev/einherjar/core` Go module.\n\n## Rationale\n\n**They always ship together.** Every Einherjar service needs all four:\n- `launcher` requires a `logging.Logger` — it calls `logz.New` in the same main function\n- `valid` returns `*xerrors.Err` — they are semantically coupled at the type level\n- No known use case requires `logz` without `xerrors`, or `launcher` without `logz`\n\n**Version coordination cost is real.** With four separate modules, a one-line fix to\n`xerrors` becomes four coordinated operations: tag xerrors, update valid's go.mod,\ntag valid, inform consumers. With one module, it is one operation.\n\n**The Spring Boot analogy holds at the right level.** Spring Boot's parent POM bundles\ndozens of interdependent libraries into a single versioned unit. Starters (`spring-boot-starter-data-jpa`) are the distribution boundary, not the individual `spring-data-commons` jar inside them. Einherjar's `core` is the parent; the starters are the distribution units.\n\n## Alternatives Considered\n\n**Four separate modules (`core-launcher`, `core-logz`, etc.).** Rejected — over-engineering at the foundational layer. There is no consumer that needs `logz` but not `xerrors`, or `launcher` but not `logz`. The additional version coordination overhead has no compensating benefit.\n\n**One flat `core` package (no sub-packages).** Rejected — importing `core` would pull all four concerns into any module that only needs errors or validation. Sub-packages preserve the ability to import only what is needed.\n\n## Consequences\n\n**Easier:** A single `go get code.nochebuena.dev/einherjar/core@v1.x.x` installs\neverything. A single version bump covers all four sub-packages. Dependency graph for\ndownstream starters stays simple.\n\n**Harder:** A change to any sub-package bumps the entire `core` version, even if the\nchange is isolated to `valid`. This is the accepted trade-off — the coupling is real;\nthe version increment reflects it.\n\n**New obligations:** Sub-packages within `core` may import each other in one direction:\n`valid` may import `xerrors`; nothing else crosses package boundaries within `core`.\nCircular imports between sub-packages are prohibited. `launcher` uses\n`contracts/logging` — not `core/logz` directly — to preserve the contracts layer.\n"
|
||
},
|
||
{
|
||
"module": "core",
|
||
"id": "ADR-002",
|
||
"title": "ADR-002: logz adopts contracts/errs instead of private duck typing",
|
||
"body": "# ADR-002: logz adopts contracts/errs instead of private duck typing\n\n- **Date:** 2026-05-28\n- **Module:** `code.nochebuena.dev/einherjar/core`\n- **Status:** Accepted\n\n## Context\n\nIn micro-lib, `logz` enriches log records when the error passed to `Logger.Error`\ncarries a machine-readable code or structured fields. It detects this at runtime\nusing two private interfaces defined inside `logz`:\n\n```go\n// inside logz — never exported\ntype errorWithCode interface {\n ErrorCode() string\n}\ntype errorWithContext interface {\n ErrorContext() map[string]any\n}\n```\n\n`xerrors.Err` implements both via its `ErrorCode()` and `ErrorContext()` methods.\n`logz` detects this with `errors.As` — without importing `xerrors`. The decoupling\nis preserved. But the contract is invisible: a developer who wants their custom error\ntype to receive log enrichment must read `logz`'s internal source to discover what\nmethods are required.\n\nThis approach is acceptable in micro-lib (single team, single repository, total\nvisibility). In a framework distributed to third-party authors, it is a maintenance\ntrap.\n\n## Decision\n\n`core/logz` imports `contracts/errs` and checks `errs.CodedError` and\n`errs.ContextualError` instead of defining private duck-typed equivalents.\n\n```go\n// core/logz/logger.go\nvar ec errs.CodedError\nif errors.As(err, \u0026ec) {\n attrs = append(attrs, \"error_code\", ec.ErrorCode())\n}\nvar ectx errs.ContextualError\nif errors.As(err, \u0026ectx) {\n for k, v := range ectx.ErrorContext() {\n attrs = append(attrs, k, v)\n }\n}\n```\n\n`core/xerrors` declares compile-time satisfaction:\n\n```go\n// core/xerrors/err.go\nvar _ errs.CodedError = (*Err)(nil)\nvar _ errs.ContextualError = (*Err)(nil)\n```\n\n## Why the `contracts/errs` Sub-package Exists\n\n`contracts` is the only Einherjar module guaranteed to have zero dependencies.\nIf `CodedError` and `ContextualError` lived in `core/xerrors`, any module\nimplementing a custom error type would be forced to import `core` — pulling the\nlauncher, logger, and validator into its dependency graph. That defeats the\nSeparated Interface pattern.\n\n`contracts/errs` is a zero-dependency home for these two 1-method interfaces.\nAny module can implement them without taking on any Einherjar dependencies.\n\n## Clean Separation Preserved\n\n`logz` still does not import `xerrors`. Both import `contracts/errs`. The\ndecoupling is maintained; the contract is now visible.\n\n```\ncontracts/errs (zero deps)\n ↑ ↑\n core/logz core/xerrors\n (imports) (implements)\n```\n\nA third-party error type implements `errs.CodedError` by satisfying one interface\nfrom `contracts` — a zero-dependency import. It receives full log enrichment\nautomatically, without any wiring code.\n\n## Alternatives Considered\n\n**Keep private duck typing.** Rejected — invisible to third-party implementors.\nA framework that requires developers to read its internal source code to understand\nwhat their types must implement is hostile to the developers it serves.\n\n**Export the interfaces from `core/logz`.** Rejected — would force custom error\ntypes to import `core`, violating the Separated Interface pattern. An error type\nshould not need to know about logging infrastructure.\n\n**One combined `RichError` interface.** Rejected — ISP. An error may carry a\nmachine-readable code without structured fields, or structured fields without a code.\nForcing both into one interface imposes unnecessary constraints on implementors.\n\n## Consequences\n\n**Easier:** Third-party error types have a clear, discoverable interface to implement:\n`go doc code.nochebuena.dev/einherjar/contracts/errs`. Compile-time verification\nreplaces runtime discovery. The framework's own `*xerrors.Err` is provably correct\nat compile time.\n\n**Harder:** `logz` now imports `contracts`, adding one dependency to its import\ngraph. This dependency is zero-cost in practice — `contracts` has no external\ndependencies and will not change its interfaces without a major version bump.\n\n**New obligations:** The `errs.CodedError.ErrorCode()` and\n`errs.ContextualError.ErrorContext()` signatures are permanent from `contracts v1.0.0`.\nAny implementor of these interfaces is guaranteed that the signatures will not change\nwithout a major version bump on `contracts`.\n"
|
||
},
|
||
{
|
||
"module": "core",
|
||
"id": "ADR-003",
|
||
"title": "ADR-003 — Config naming convention and caarlos0/env tag standard",
|
||
"body": "# ADR-003 — Config naming convention and caarlos0/env tag standard\n\n**Status:** Accepted\n**Date:** 2026-05-28\n\n---\n\n## Context\n\n`launcher` and `logz` originally followed micro-lib's `Options` naming for their\nconfiguration structs. `Options` has no env tags, so there is no standard way for\napplications to populate framework configuration from environment variables without\nhardcoding field assignments.\n\nThe project requires a consistent, library-agnostic approach to environment-based\nconfiguration across all Einherjar modules.\n\n---\n\n## Decision\n\n1. **Naming:** Every package that accepts external configuration exposes a `Config`\n struct. `Options` is not used for configuration types in Einherjar — it is reserved\n for functional options patterns if ever needed.\n\n2. **Tag standard:** `Config` struct fields that can be sourced from environment\n variables carry `env` and `envDefault` struct tags following the `caarlos0/env`\n library's syntax:\n\n ```go\n type Config struct {\n Level slog.Level `env:\"EINHERJAR_LOG_LEVEL\" envDefault:\"INFO\"`\n JSON bool `env:\"EINHERJAR_LOG_JSON\" envDefault:\"false\"`\n }\n ```\n\n3. **No library import:** Modules do not import `caarlos0/env` or any other env\n loader. The tags are metadata only. Applications choose their own loader and call\n it against the `Config` struct before passing it to `New()`.\n\n4. **Env var prefix:** All Einherjar-owned variables use the `EINHERJAR_` prefix,\n consistent with the existing `EINHERJAR_BANNER` convention.\n\n5. **Programmatic-only fields:** Fields that cannot be sourced from environment\n variables (e.g. `io.Writer`, `[]any`) are included in `Config` without tags.\n They default to sensible zero-value behaviour and are set directly by the caller.\n\n---\n\n## Rationale\n\n- **Library-agnostic:** Different applications use different env loaders (`caarlos0/env`,\n `sethvargo/go-envconfig`, `kelseyhightower/envconfig`, YAML via Viper, etc.).\n Carrying only tags means no dependency is forced.\n- **Consistent with the project:** micro-lib starters (`firebase`, `postgres`,\n `mysql`, `sqlite`, `httpserver`, etc.) already use this pattern.\n- **Discoverable defaults:** `envDefault` values are readable in the struct definition\n itself — no need to trace through constructor logic to find what defaults are applied.\n- **Zero cost when not used:** An application that populates `Config` directly in code\n pays no overhead for the tags.\n\n---\n\n## Consequences\n\n- All future Einherjar packages with external config must follow this convention.\n- Starter packages (`db-postgres`, `cache-valkey`, etc.) will expose `Config` structs\n with env tags when they are implemented.\n- The `defaultComponentStopTimeout` constant in `launcher` is kept alongside\n `Config.envDefault` so the programmatic default (used when zero `time.Duration` is\n passed) remains explicit in code.\n"
|
||
}
|
||
],
|
||
"examples": [
|
||
{
|
||
"module": "core",
|
||
"subPackage": "",
|
||
"title": "Launcher",
|
||
"code": "import (\n \"code.nochebuena.dev/einherjar/core/launcher\"\n \"code.nochebuena.dev/einherjar/core/logz\"\n)\n\nlogger := logz.New(logz.Config{JSON: true, StaticArgs: []any{\"service\", \"api\"}})\n\nlc := launcher.New(logger)\nlc.Append(db, cache, server)\nlc.BeforeStart(func() error {\n return server.RegisterRoutes(db, cache)\n})\n\nif err := lc.Run(); err != nil {\n logger.Error(\"launcher failed\", err)\n os.Exit(1)\n}",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "",
|
||
"title": "Logger",
|
||
"code": "import \"code.nochebuena.dev/einherjar/core/logz\"\n\nlogger := logz.New(logz.Config{Level: slog.LevelDebug, JSON: true})\n\n// Attach request context in middleware\nctx = logz.WithRequestID(ctx, requestID)\nctx = logz.WithField(ctx, \"user_id\", userID)\n\n// Enrich logger from context in handlers\nreqLogger := logger.WithContext(ctx)\nreqLogger.Info(\"handling request\", \"path\", r.URL.Path)\n\n// Error enrichment is automatic — no extra code needed\nreqLogger.Error(\"query failed\", err) // appends error_code and context fields",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "",
|
||
"title": "Errors",
|
||
"code": "import \"code.nochebuena.dev/einherjar/core/xerrors\"\n\n// Named constructors for common cases\nerr := xerrors.NotFound(\"user %s not found\", userID)\nerr := xerrors.InvalidInput(\"email is required\")\nerr := xerrors.Aborted(\"order modified by another session\")\n\n// Builder pattern for structured context\nerr := xerrors.New(xerrors.ErrInvalidInput, \"validation failed\").\n WithContext(\"field\", \"email\").\n WithContext(\"rule\", \"required\").\n WithError(cause)\n\n// Inspecting errors\nvar e *xerrors.Err\nif errors.As(err, \u0026e) {\n switch e.Code() {\n case xerrors.ErrNotFound: // HTTP 404\n case xerrors.ErrUnauthorized: // HTTP 401\n case xerrors.ErrInvalidInput: // HTTP 400\n }\n}",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "",
|
||
"title": "Validation",
|
||
"code": "import \"code.nochebuena.dev/einherjar/core/valid\"\n\ntype CreateUserReq struct {\n Email string `json:\"email\" validate:\"required,email\"`\n Age int `json:\"age\" validate:\"gte=18\"`\n}\n\nv := valid.New() // English messages\n// v := valid.New(valid.WithMessageProvider(valid.SpanishMessages))\n\nif err := v.Struct(req); err != nil {\n var xe *xerrors.Err\n errors.As(err, \u0026xe)\n // xe.Code() == xerrors.ErrInvalidInput\n // xe.Fields() == {\"field\": \"email\", \"tag\": \"required\"}\n // xe.Message() == \"field 'email' is required\"\n}",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "",
|
||
"title": "Validation",
|
||
"code": "import \"strings\"\n\nv := valid.New(\n valid.WithCustomValidator(\"nohttp\", func(fl valid.FieldLevel) bool {\n return !strings.HasPrefix(fl.Field().String(), \"http://\")\n }),\n valid.WithMessageProvider(valid.OverrideProvider(\n map[string]func(field, param string) string{\n \"nohttp\": func(field, _ string) string {\n return fmt.Sprintf(\"field '%s' must not use http://\", field)\n },\n },\n valid.DefaultMessages,\n )),\n)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "",
|
||
"title": "Dependency Rules",
|
||
"code": "contracts (zero dependencies)\n ↑\n core (depends on contracts only)\n ↑\n starters (depend on core + contracts)\n ↑\n your app",
|
||
"language": ""
|
||
},
|
||
{
|
||
"module": "core",
|
||
"subPackage": "",
|
||
"title": "Verification",
|
||
"code": "cd core/\ngo build ./... # must compile clean\ngo vet ./... # no warnings\ngo test ./... # structural + behavioural compliance passes\ngofmt -l . # no output",
|
||
"language": "bash"
|
||
}
|
||
],
|
||
"compliance": {
|
||
"interfaceAsserts": [
|
||
{
|
||
"module": "core",
|
||
"interface": "logging.Logger",
|
||
"impl": "logz.New(logz.Config{})",
|
||
"file": "compliance_test.go",
|
||
"line": 27
|
||
},
|
||
{
|
||
"module": "core",
|
||
"interface": "errs.CodedError",
|
||
"impl": "(*xerrors.Err)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 28
|
||
},
|
||
{
|
||
"module": "core",
|
||
"interface": "errs.ContextualError",
|
||
"impl": "(*xerrors.Err)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 29
|
||
},
|
||
{
|
||
"module": "core",
|
||
"interface": "valid.Validator",
|
||
"impl": "valid.New()",
|
||
"file": "compliance_test.go",
|
||
"line": 30
|
||
},
|
||
{
|
||
"module": "core",
|
||
"interface": "valid.MessageProvider",
|
||
"impl": "valid.DefaultMessages",
|
||
"file": "compliance_test.go",
|
||
"line": 31
|
||
},
|
||
{
|
||
"module": "core",
|
||
"interface": "valid.MessageProvider",
|
||
"impl": "valid.SpanishMessages",
|
||
"file": "compliance_test.go",
|
||
"line": 32
|
||
},
|
||
{
|
||
"module": "core",
|
||
"interface": "lifecycle.Component",
|
||
"impl": "(*mockComponent)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 102
|
||
}
|
||
],
|
||
"tests": [
|
||
{
|
||
"module": "core",
|
||
"name": "TestAtMostOneExportedTypePerFile",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 36
|
||
},
|
||
{
|
||
"module": "core",
|
||
"name": "TestLauncherLifecycleOrder",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 104
|
||
},
|
||
{
|
||
"module": "core",
|
||
"name": "TestLogzErrorEnrichment",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 140
|
||
},
|
||
{
|
||
"module": "core",
|
||
"name": "TestLogzContextEnrichment",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 158
|
||
},
|
||
{
|
||
"module": "core",
|
||
"name": "TestXerrorsCodeRoundtrip",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 178
|
||
},
|
||
{
|
||
"module": "core",
|
||
"name": "TestXerrorsContextRoundtrip",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 188
|
||
},
|
||
{
|
||
"module": "core",
|
||
"name": "TestXerrorsWrapUnwrap",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 202
|
||
},
|
||
{
|
||
"module": "core",
|
||
"name": "TestXerrorsMarshalJSON",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 211
|
||
},
|
||
{
|
||
"module": "core",
|
||
"name": "TestXerrorsConvenienceConstructors",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 232
|
||
},
|
||
{
|
||
"module": "core",
|
||
"name": "TestValidValidStruct",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 263
|
||
},
|
||
{
|
||
"module": "core",
|
||
"name": "TestValidRequiredFieldMissing",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 273
|
||
},
|
||
{
|
||
"module": "core",
|
||
"name": "TestValidNonStructReturnsInternal",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 294
|
||
},
|
||
{
|
||
"module": "core",
|
||
"name": "TestValidSpanishMessages",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 309
|
||
},
|
||
{
|
||
"module": "core",
|
||
"name": "TestValidCustomValidator",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 325
|
||
},
|
||
{
|
||
"module": "core",
|
||
"name": "TestValidOverrideProvider",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 352
|
||
}
|
||
]
|
||
},
|
||
"readme": "# einherjar/core\n\n[](https://code.nochebuena.dev/einherjar/core)\n[](LICENSE)\n[](https://go.dev)\n\n\u003e The chosen warriors do not choose their weapons. They forge them.\n\n`code.nochebuena.dev/einherjar/core` is the foundational implementation module of the\nEinherjar framework. It sits directly above `contracts` in the dependency graph and\nprovides the concrete tools every service needs before anything else can start:\na lifecycle runner, a structured logger, typed errors, and struct validation.\n\n---\n\n## What Is Einherjar?\n\nIn Norse mythology, the Einherjar are the chosen warriors of Valhalla — selected not\nfor glory, but to be ready for what comes after. They train. They prepare. They build\nthe capability that others will rely on.\n\nThis framework is named for that purpose. Every module is a piece of that preparation:\nbuilt carefully, documented for those who were never in the room, and designed to hold\nunder pressure.\n\n---\n\n## Sub-packages\n\n| Package | Import path | Purpose |\n|---|---|---|\n| `launcher` | `.../core/launcher` | Application lifecycle — init, start, shutdown |\n| `logz` | `.../core/logz` | Structured, leveled logging via `log/slog` |\n| `xerrors` | `.../core/xerrors` | Typed error codes with context enrichment |\n| `valid` | `.../core/valid` | Struct validation with pluggable i18n messages |\n\nAll four are in one module because they ship together in every Einherjar service.\nSee [ADR-001](docs/adr/ADR-001-core-module-composition.md).\n\n---\n\n## Usage\n\n### Launcher\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/core/launcher\"\n \"code.nochebuena.dev/einherjar/core/logz\"\n)\n\nlogger := logz.New(logz.Config{JSON: true, StaticArgs: []any{\"service\", \"api\"}})\n\nlc := launcher.New(logger)\nlc.Append(db, cache, server)\nlc.BeforeStart(func() error {\n return server.RegisterRoutes(db, cache)\n})\n\nif err := lc.Run(); err != nil {\n logger.Error(\"launcher failed\", err)\n os.Exit(1)\n}\n```\n\nEnvironment variables (uses `caarlos0/env` tag syntax — application supplies the loader):\n\n| Variable | Default | Effect |\n|---|---|---|\n| `EINHERJAR_BANNER` | _(on)_ | Set to `off` or `false` to suppress the startup banner |\n| `EINHERJAR_COMPONENT_STOP_TIMEOUT` | `15s` | Maximum time per component `OnStop` |\n\n### Logger\n\n```go\nimport \"code.nochebuena.dev/einherjar/core/logz\"\n\nlogger := logz.New(logz.Config{Level: slog.LevelDebug, JSON: true})\n\n// Attach request context in middleware\nctx = logz.WithRequestID(ctx, requestID)\nctx = logz.WithField(ctx, \"user_id\", userID)\n\n// Enrich logger from context in handlers\nreqLogger := logger.WithContext(ctx)\nreqLogger.Info(\"handling request\", \"path\", r.URL.Path)\n\n// Error enrichment is automatic — no extra code needed\nreqLogger.Error(\"query failed\", err) // appends error_code and context fields\n```\n\nEnvironment variables:\n\n| Variable | Default | Effect |\n|---|---|---|\n| `EINHERJAR_LOG_LEVEL` | `INFO` | Minimum log level (`DEBUG`, `INFO`, `WARN`, `ERROR`) |\n| `EINHERJAR_LOG_JSON` | `false` | JSON output when `true` |\n\n### Errors\n\n```go\nimport \"code.nochebuena.dev/einherjar/core/xerrors\"\n\n// Named constructors for common cases\nerr := xerrors.NotFound(\"user %s not found\", userID)\nerr := xerrors.InvalidInput(\"email is required\")\nerr := xerrors.Aborted(\"order modified by another session\")\n\n// Builder pattern for structured context\nerr := xerrors.New(xerrors.ErrInvalidInput, \"validation failed\").\n WithContext(\"field\", \"email\").\n WithContext(\"rule\", \"required\").\n WithError(cause)\n\n// Inspecting errors\nvar e *xerrors.Err\nif errors.As(err, \u0026e) {\n switch e.Code() {\n case xerrors.ErrNotFound: // HTTP 404\n case xerrors.ErrUnauthorized: // HTTP 401\n case xerrors.ErrInvalidInput: // HTTP 400\n }\n}\n```\n\n### Validation\n\n```go\nimport \"code.nochebuena.dev/einherjar/core/valid\"\n\ntype CreateUserReq struct {\n Email string `json:\"email\" validate:\"required,email\"`\n Age int `json:\"age\" validate:\"gte=18\"`\n}\n\nv := valid.New() // English messages\n// v := valid.New(valid.WithMessageProvider(valid.SpanishMessages))\n\nif err := v.Struct(req); err != nil {\n var xe *xerrors.Err\n errors.As(err, \u0026xe)\n // xe.Code() == xerrors.ErrInvalidInput\n // xe.Fields() == {\"field\": \"email\", \"tag\": \"required\"}\n // xe.Message() == \"field 'email' is required\"\n}\n```\n\nAll go-playground/validator built-in tags have specific messages in both `DefaultMessages`\nand `SpanishMessages`. The generic fallback only fires for unknown tags.\n\n#### Custom validators\n\n```go\nimport \"strings\"\n\nv := valid.New(\n valid.WithCustomValidator(\"nohttp\", func(fl valid.FieldLevel) bool {\n return !strings.HasPrefix(fl.Field().String(), \"http://\")\n }),\n valid.WithMessageProvider(valid.OverrideProvider(\n map[string]func(field, param string) string{\n \"nohttp\": func(field, _ string) string {\n return fmt.Sprintf(\"field '%s' must not use http://\", field)\n },\n },\n valid.DefaultMessages,\n )),\n)\n```\n\n`OverrideProvider` chains a tag→message map with a fallback provider, so custom tag\nmessages are handled without re-implementing all built-ins.\n\n---\n\n## Error Codes\n\n`xerrors` provides the full gRPC canonical error code set plus HTTP 410 (`ErrGone`):\n\n| Constant | Wire value | HTTP | When to use |\n|---|---|---|---|\n| `ErrInvalidInput` | `INVALID_ARGUMENT` | 400 | Malformed or invalid request data |\n| `ErrOutOfRange` | `OUT_OF_RANGE` | 400 | Valid value but outside accepted bounds |\n| `ErrUnauthorized` | `UNAUTHENTICATED` | 401 | Missing or invalid credentials |\n| `ErrPermissionDenied` | `PERMISSION_DENIED` | 403 | Authenticated but not authorised |\n| `ErrNotFound` | `NOT_FOUND` | 404 | Resource does not exist |\n| `ErrAlreadyExists` | `ALREADY_EXISTS` | 409 | Creation conflict (duplicate) |\n| `ErrAborted` | `ABORTED` | 409 | Concurrent modification; retry may succeed |\n| `ErrGone` | `GONE` | 410 | Resource permanently deleted |\n| `ErrPreconditionFailed` | `FAILED_PRECONDITION` | 412 | Business rule blocks the operation |\n| `ErrRateLimited` | `RESOURCE_EXHAUSTED` | 429 | Rate limit or quota exceeded |\n| `ErrCancelled` | `CANCELLED` | 499 | Request cancelled by the caller |\n| `ErrInternal` | `INTERNAL` | 500 | Unexpected server-side failure |\n| `ErrDataLoss` | `DATA_LOSS` | 500 | Unrecoverable data corruption |\n| `ErrNotImplemented` | `UNIMPLEMENTED` | 501 | Operation not implemented |\n| `ErrUnavailable` | `UNAVAILABLE` | 503 | Service temporarily unavailable |\n| `ErrDeadlineExceeded` | `DEADLINE_EXCEEDED` | 504 | Operation timed out |\n\nWire values are stable across versions and safe to persist, send over the network,\nor switch on in client code.\n\n---\n\n## Dependency Rules\n\n```\ncontracts (zero dependencies)\n ↑\n core (depends on contracts only)\n ↑\n starters (depend on core + contracts)\n ↑\n your app\n```\n\n`core` imports `contracts`. Nothing above `core` in this chain may import `core`\ndirectly — they depend on starters, which compose core's sub-packages behind\nframework-specific APIs.\n\n---\n\n## Verification\n\n```bash\ncd core/\ngo build ./... # must compile clean\ngo vet ./... # no warnings\ngo test ./... # structural + behavioural compliance passes\ngofmt -l . # no output\n```\n\n---\n\n## Architecture Decisions\n\n| ADR | Title |\n|---|---|\n| [ADR-001](docs/adr/ADR-001-core-module-composition.md) | Four sub-packages in one Go module |\n| [ADR-002](docs/adr/ADR-002-logz-contracts-errs.md) | logz adopts contracts/errs instead of private duck typing |\n| [ADR-003](docs/adr/ADR-003-config-env-tags.md) | Config naming convention and caarlos0/env tag standard |\n\n---\n\n\u003e *They were not chosen because they were the strongest.*\n\u003e *They were chosen because they understood what they were building toward.*\n",
|
||
"changelog": "# Changelog — einherjar/core\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped `contracts` to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped `contracts` to v1.1.1 (framework version alignment). No code or API changes.\n\n## [1.1.0] — 2026-08-07\n\nCoordinated framework release. Documentation fix plus the framework version bump.\n\n### Fixed\n\n- **Package doc examples referenced the retired `logz.Options`.** `logz.New` takes\n `logz.Config`; the `launcher` and `logz` package docs showed `logz.New(logz.Options{…})`,\n which does not compile. Both corrected to `logz.Config`, verified by compiling the example\n patterns against the real API.\n\n### Changed\n\n- Bumped `contracts` to v1.1.0 (framework version alignment).\n\n## [1.0.0] — 2026-05-28\n\n### Added\n\n#### `launcher`\n\n- `Launcher` interface — `Append`, `BeforeStart`, `Run`, `Shutdown`\n- `New(logger logging.Logger, opts ...Options) Launcher` — constructs the lifecycle\n orchestrator; takes `contracts/logging.Logger` (not the concrete `logz` type)\n- `Hook` type (`func() error`) — assembly-phase callback registered via `BeforeStart`\n- `Config` struct — `ComponentStopTimeout time.Duration` with `env:\"EINHERJAR_COMPONENT_STOP_TIMEOUT\" envDefault:\"15s\"` (`caarlos0/env` syntax)\n- Startup banner — printed to stdout before any slog output; disabled via\n `EINHERJAR_BANNER=off` or `EINHERJAR_BANNER=false`\n- Components accepted as `lifecycle.Component` from `contracts` — not a locally\n defined interface; any type that satisfies `contracts/lifecycle.Component` is\n directly compatible\n\n#### `logz`\n\n- `Config` struct — `Level slog.Level`, `JSON bool`, `StaticArgs []any`, `Writer io.Writer`\n - `Level` and `JSON` carry `env` and `envDefault` tags (`caarlos0/env` syntax); `StaticArgs` and `Writer` are programmatic-only fields without tags\n - Env vars: `EINHERJAR_LOG_LEVEL` (default `INFO`), `EINHERJAR_LOG_JSON` (default `false`)\n- `New(cfg Config) logging.Logger` — returns `contracts/logging.Logger`; the\n concrete `slogLogger` struct is unexported\n- Error enrichment — when the error passed to `Logger.Error` satisfies\n `errs.CodedError` or `errs.ContextualError` (from `contracts/errs`), the\n corresponding `error_code` field and context key-value pairs are automatically\n appended to the log record; this replaces the private duck-typed bridge used in\n micro-lib's `logz`\n- Context helpers — `WithRequestID`, `GetRequestID`, `WithField`, `WithFields`\n- `Logger.WithContext(ctx)` — extracts `request_id` and extra fields from context\n and attaches them to every subsequent log record\n\n#### `xerrors`\n\n- `Code` type (stable string wire values, gRPC-aligned)\n- **16 error code constants** — complete gRPC canonical set plus `ErrGone` (HTTP 410)\n - New over micro-lib: `ErrOutOfRange` (gRPC OUT_OF_RANGE, HTTP 400),\n `ErrAborted` (gRPC ABORTED, HTTP 409), `ErrDataLoss` (gRPC DATA_LOSS, HTTP 500)\n- `Code.Description()` — human-readable description for each code\n- `Err` struct — `code`, `message`, `err` (cause), `fields` (context), `platformCode`\n- Base constructors: `New(code, message)`, `Wrap(code, message, err)`\n- **16 convenience constructors** (one per code): `InvalidInput`, `OutOfRange`,\n `Unauthorized`, `PermissionDenied`, `NotFound`, `AlreadyExists`, `Aborted`,\n `Gone`, `PreconditionFailed`, `RateLimited`, `Cancelled`, `Internal`, `DataLoss`,\n `NotImplemented`, `Unavailable`, `DeadlineExceeded`\n- Builder methods: `WithContext`, `WithError`, `WithPlatformCode`\n- Accessors: `Code()`, `Message()`, `Fields()`, `PlatformCode()`, `Detailed()`\n- Standard interfaces: `error`, `Unwrap`, `json.Marshaler`\n- Compile-time assertions: `var _ errs.CodedError = (*Err)(nil)` and\n `var _ errs.ContextualError = (*Err)(nil)` — formalises the duck-type bridge\n from micro-lib into an explicit, verifiable contract\n\n#### `valid`\n\n- `Validator` interface — `Struct(v any) error`\n- `New(opts ...Option) Validator` — constructs a validator backed by\n `go-playground/validator/v10` (backend is hidden; never exposed in the public API)\n- `MessageProvider` interface — `Message(field, tag, param string) string`\n- `DefaultMessages` — built-in English message provider\n- `SpanishMessages` — opt-in Spanish message provider\n- `Option` type and `WithMessageProvider(mp MessageProvider) Option`\n- `FieldLevel` interface — passed to custom validator functions; exposes `Field() reflect.Value`, `Param() string`, `FieldName() string`; go-playground backend never visible\n- `WithCustomValidator(tag string, fn func(FieldLevel) bool) Option` — registers a custom validation tag at construction time; panics on empty or conflicting tag\n- `OverrideProvider(handlers map[string]func(field, param string) string, base MessageProvider) MessageProvider` — composes a tag→message handler map with a fallback; use for custom tag messages without re-implementing built-ins\n- **Full built-in tag coverage** in `DefaultMessages` and `SpanishMessages` — all go-playground/validator tags have specific messages (fields, network, strings, format, comparisons, other; ~150 tags total)\n- Field names in error context prefer the json struct tag, falling back to the Go\n field name\n- Error codes: `ErrInvalidInput` for constraint failures, `ErrInternal` for\n non-struct arguments — both returned as `*xerrors.Err`\n\n### Design Notes\n\n1. **Contracts as the source of truth.** `launcher` accepts `lifecycle.Component`\n and `logging.Logger` from `contracts`. It does not define its own lifecycle\n interface. Any type that satisfies the contracts interface is directly compatible —\n no adapters needed.\n\n2. **Duck typing replaced by explicit interfaces.** micro-lib's `logz` detected\n enrichable errors via private `errorWithCode`/`errorWithContext` interfaces.\n `core/logz` detects them via `contracts/errs.CodedError` and `contracts/errs.ContextualError`.\n The decoupling (logz does not import xerrors) is preserved; the contract is now\n visible. See [ADR-002](docs/adr/ADR-002-logz-contracts-errs.md).\n\n3. **Complete gRPC error code set.** micro-lib's `xerrors` had 13 codes. `core/xerrors`\n adds the three missing gRPC codes (`OUT_OF_RANGE`, `ABORTED`, `DATA_LOSS`) and\n provides a named convenience constructor for every code — `New()` and `Wrap()` are\n reserved for edge cases.\n\n4. **One module, four sub-packages.** The consolidation eliminates four-way version\n coordination for a set of packages that always ship and upgrade together.\n See [ADR-001](docs/adr/ADR-001-core-module-composition.md).\n\n5. **Startup banner.** The launcher prints an ASCII art banner to stdout before any\n structured log output. It is disabled via `EINHERJAR_BANNER=off`, not via code\n changes, so production deployments can suppress it without modifying the service.\n\n---\n\n[1.0.0]: https://code.nochebuena.dev/einherjar/core/releases/tag/v1.0.0\n"
|
||
},
|
||
{
|
||
"name": "db-mysql",
|
||
"importPath": "code.nochebuena.dev/einherjar/db-mysql",
|
||
"purpose": "A warrior's deeds are committed to stone so that what they built survives the builder.",
|
||
"doc": "Package mysql provides a database/sql-backed MySQL client with lifecycle\nmanagement, health checks, and unit-of-work transaction support for\nEinherjar applications.\n\n# Overview\n\n[New] returns a [Component] that manages a *sql.DB connection pool,\nsatisfies the [lifecycle.Component] lifecycle hooks (OnInit, OnStart, OnStop),\nand implements [observability.Checkable] with critical priority.\n\n[NewUnitOfWork] wraps multiple repository operations in a single transaction\nvia context injection — no transaction object is passed between functions.\n\n# Lifecycle Registration\n\n\tdb := mysql.New(logger, cfg)\n\tlc.Append(db) // lifecycle: OnInit → OnStart → OnStop\n\ndb satisfies [observability.Checkable], so it can back a health endpoint via\nweb/health.NewHandler:\n\n\tsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)\n\n# Querying\n\nRepository code receives a [Provider] or [Executor] and calls [Provider.GetExecutor]\nto obtain the active transaction (if inside a [UnitOfWork]) or the pool:\n\n\texec := db.GetExecutor(ctx)\n\trow := exec.QueryRowContext(ctx, \"SELECT id FROM users WHERE email = ?\", email)\n\tif err := row.Scan(\u0026id); err != nil {\n\t return db.HandleError(err)\n\t}\n\n# Unit of Work\n\n[NewUnitOfWork] wraps operations in a single transaction. The transaction is\ninjected into the context; [Provider.GetExecutor] returns it automatically.\n\n\tuow := mysql.NewUnitOfWork(logger, db)\n\terr := uow.Do(ctx, func(ctx context.Context) error {\n\t exec := db.GetExecutor(ctx) // returns active Tx\n\t _, err := exec.ExecContext(ctx, \"INSERT INTO orders ...\")\n\t return err\n\t})\n\n# Error Handling\n\n[HandleError] translates MySQL driver errors into typed [xerrors] values.\nCall it at every point where a driver error is first observed:\n\n\tif err := row.Scan(\u0026out); err != nil {\n\t return db.HandleError(err)\n\t}\n\nMapped codes:\n - 1062 (ER_DUP_ENTRY) → ErrAlreadyExists\n - 1216, 1217, 1451, 1452 (FK errors) → ErrInvalidInput\n - sql.ErrNoRows → ErrNotFound\n - all others → ErrInternal",
|
||
"goVersion": "1.26",
|
||
"dependsOn": [
|
||
"contracts",
|
||
"core"
|
||
],
|
||
"subPackages": [
|
||
{
|
||
"name": "",
|
||
"importPath": "code.nochebuena.dev/einherjar/db-mysql",
|
||
"doc": "Package mysql provides a database/sql-backed MySQL client with lifecycle\nmanagement, health checks, and unit-of-work transaction support for\nEinherjar applications.\n\n# Overview\n\n[New] returns a [Component] that manages a *sql.DB connection pool,\nsatisfies the [lifecycle.Component] lifecycle hooks (OnInit, OnStart, OnStop),\nand implements [observability.Checkable] with critical priority.\n\n[NewUnitOfWork] wraps multiple repository operations in a single transaction\nvia context injection — no transaction object is passed between functions.\n\n# Lifecycle Registration\n\n\tdb := mysql.New(logger, cfg)\n\tlc.Append(db) // lifecycle: OnInit → OnStart → OnStop\n\ndb satisfies [observability.Checkable], so it can back a health endpoint via\nweb/health.NewHandler:\n\n\tsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)\n\n# Querying\n\nRepository code receives a [Provider] or [Executor] and calls [Provider.GetExecutor]\nto obtain the active transaction (if inside a [UnitOfWork]) or the pool:\n\n\texec := db.GetExecutor(ctx)\n\trow := exec.QueryRowContext(ctx, \"SELECT id FROM users WHERE email = ?\", email)\n\tif err := row.Scan(\u0026id); err != nil {\n\t return db.HandleError(err)\n\t}\n\n# Unit of Work\n\n[NewUnitOfWork] wraps operations in a single transaction. The transaction is\ninjected into the context; [Provider.GetExecutor] returns it automatically.\n\n\tuow := mysql.NewUnitOfWork(logger, db)\n\terr := uow.Do(ctx, func(ctx context.Context) error {\n\t exec := db.GetExecutor(ctx) // returns active Tx\n\t _, err := exec.ExecContext(ctx, \"INSERT INTO orders ...\")\n\t return err\n\t})\n\n# Error Handling\n\n[HandleError] translates MySQL driver errors into typed [xerrors] values.\nCall it at every point where a driver error is first observed:\n\n\tif err := row.Scan(\u0026out); err != nil {\n\t return db.HandleError(err)\n\t}\n\nMapped codes:\n - 1062 (ER_DUP_ENTRY) → ErrAlreadyExists\n - 1216, 1217, 1451, 1452 (FK errors) → ErrInvalidInput\n - sql.ErrNoRows → ErrNotFound\n - all others → ErrInternal"
|
||
}
|
||
],
|
||
"symbols": [
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Component",
|
||
"signature": "type Component interface",
|
||
"doc": "Component bundles the full MySQL capability: lifecycle management,\nhealth reporting, and the database [Provider] interface.\nRegister with launcher and health before starting.",
|
||
"file": "component.go",
|
||
"line": 13,
|
||
"methods": [
|
||
{
|
||
"signature": "lifecycle.Component"
|
||
},
|
||
{
|
||
"signature": "observability.Checkable"
|
||
},
|
||
{
|
||
"signature": "observability.Identifiable"
|
||
},
|
||
{
|
||
"signature": "Provider"
|
||
},
|
||
{
|
||
"name": "Stats",
|
||
"signature": "Stats() sql.DBStats",
|
||
"doc": "Stats returns connection pool statistics. Returns a zero-value stat\nwhen the pool has not been initialized yet."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "New",
|
||
"signature": "func New(logger logging.Logger, cfg Config) Component",
|
||
"doc": "New returns a Component backed by the given configuration.\nThe connection pool is not created until OnInit is called.",
|
||
"file": "new.go",
|
||
"line": 24
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "Config",
|
||
"signature": "type Config struct",
|
||
"doc": "Config holds MySQL connection settings. Required fields must be supplied\nby the caller; optional fields have production-safe defaults via [DefaultConfig].\n\nNote on Collation: go-sql-driver v1.8.x negotiates the connection collation\nvia a 1-byte handshake ID (max 255). MariaDB 11.4+ collations such as\nutf8mb4_uca1400_as_cs carry IDs \u003e 255 and cannot be set through the DSN\ncollation parameter. Set the desired collation at the database/table level\nin your schema migrations instead.",
|
||
"file": "config.go",
|
||
"line": 16,
|
||
"fields": [
|
||
{
|
||
"name": "Host",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_MYSQL_HOST,required\""
|
||
},
|
||
{
|
||
"name": "Port",
|
||
"type": "int",
|
||
"tag": "env:\"EINHERJAR_MYSQL_PORT\" envDefault:\"3306\""
|
||
},
|
||
{
|
||
"name": "User",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_MYSQL_USER,required\""
|
||
},
|
||
{
|
||
"name": "Password",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_MYSQL_PASSWORD,required\""
|
||
},
|
||
{
|
||
"name": "Name",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_MYSQL_NAME,required\""
|
||
},
|
||
{
|
||
"name": "MaxConns",
|
||
"type": "int",
|
||
"tag": "env:\"EINHERJAR_MYSQL_MAX_CONNS\" envDefault:\"5\""
|
||
},
|
||
{
|
||
"name": "MinConns",
|
||
"type": "int",
|
||
"tag": "env:\"EINHERJAR_MYSQL_MIN_CONNS\" envDefault:\"2\""
|
||
},
|
||
{
|
||
"name": "MaxConnLifetime",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_MYSQL_MAX_CONN_LIFETIME\" envDefault:\"1h\""
|
||
},
|
||
{
|
||
"name": "MaxConnIdleTime",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_MYSQL_MAX_CONN_IDLE_TIME\" envDefault:\"30m\""
|
||
},
|
||
{
|
||
"name": "Charset",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_MYSQL_CHARSET\" envDefault:\"utf8mb4\"",
|
||
"doc": "Charset is the connection character set. Defaults to \"utf8mb4\"."
|
||
},
|
||
{
|
||
"name": "Loc",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_MYSQL_LOC\" envDefault:\"UTC\"",
|
||
"doc": "Loc is the IANA timezone name for time.Time ↔ MySQL DATETIME conversion. Defaults to \"UTC\"."
|
||
},
|
||
{
|
||
"name": "ParseTime",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_MYSQL_PARSE_TIME\" envDefault:\"true\"",
|
||
"doc": "ParseTime controls whether DATE/DATETIME columns are mapped to time.Time. Defaults to \"true\"."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "DefaultConfig",
|
||
"signature": "func DefaultConfig() Config",
|
||
"doc": "DefaultConfig returns a Config with all optional fields set to production-safe defaults.\nCallers must supply Host, Port, User, Password, and Name.",
|
||
"file": "config.go",
|
||
"line": 36
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "Config.DSN",
|
||
"signature": "func (c Config) DSN() string",
|
||
"doc": "DSN constructs a MySQL DSN from the configuration.",
|
||
"file": "config.go",
|
||
"line": 50
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Executor",
|
||
"signature": "type Executor interface",
|
||
"doc": "Executor is the shared query interface for both the connection pool and\nan active transaction. Repository code accepts Executor so it works\nidentically inside and outside a [UnitOfWork].",
|
||
"file": "executor.go",
|
||
"line": 11,
|
||
"methods": [
|
||
{
|
||
"name": "ExecContext",
|
||
"signature": "ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)",
|
||
"doc": "ExecContext executes a query that returns no rows."
|
||
},
|
||
{
|
||
"name": "QueryContext",
|
||
"signature": "QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)",
|
||
"doc": "QueryContext executes a query that returns rows."
|
||
},
|
||
{
|
||
"name": "QueryRowContext",
|
||
"signature": "QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row",
|
||
"doc": "QueryRowContext executes a query that returns at most one row."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Provider",
|
||
"signature": "type Provider interface",
|
||
"doc": "Provider is the database access interface consumed by repositories and services.\nAll methods are safe for concurrent use.",
|
||
"file": "provider.go",
|
||
"line": 10,
|
||
"methods": [
|
||
{
|
||
"name": "GetExecutor",
|
||
"signature": "GetExecutor(ctx context.Context) Executor",
|
||
"doc": "GetExecutor returns the active transaction injected by [UnitOfWork] if one\nis present in ctx, otherwise returns the connection pool."
|
||
},
|
||
{
|
||
"name": "Begin",
|
||
"signature": "Begin(ctx context.Context) (Tx, error)",
|
||
"doc": "Begin starts a new transaction with default options."
|
||
},
|
||
{
|
||
"name": "BeginTx",
|
||
"signature": "BeginTx(ctx context.Context, opts *sql.TxOptions) (Tx, error)",
|
||
"doc": "BeginTx starts a new transaction with the given options."
|
||
},
|
||
{
|
||
"name": "Ping",
|
||
"signature": "Ping(ctx context.Context) error",
|
||
"doc": "Ping verifies that the database connection is alive."
|
||
},
|
||
{
|
||
"name": "HandleError",
|
||
"signature": "HandleError(err error) error",
|
||
"doc": "HandleError maps a MySQL driver error to a typed [xerrors] value.\nCall this at every point where a driver error is first observed."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Tx",
|
||
"signature": "type Tx interface",
|
||
"doc": "Tx extends [Executor] with commit and rollback. Obtained via [Provider.Begin]\nor [Provider.BeginTx] when manual transaction control is needed.\nPrefer [UnitOfWork] for the common case of a single transactional unit.\n\nNote: database/sql does not support per-call context on Commit or Rollback.\nThese methods honestly omit ctx rather than accepting and ignoring it.",
|
||
"file": "tx.go",
|
||
"line": 9,
|
||
"methods": [
|
||
{
|
||
"signature": "Executor"
|
||
},
|
||
{
|
||
"name": "Commit",
|
||
"signature": "Commit() error",
|
||
"doc": "Commit commits the transaction."
|
||
},
|
||
{
|
||
"name": "Rollback",
|
||
"signature": "Rollback() error",
|
||
"doc": "Rollback rolls back the transaction. Safe to call after Commit."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "UnitOfWork",
|
||
"signature": "type UnitOfWork interface",
|
||
"doc": "UnitOfWork wraps a set of repository operations in a single database transaction.\nThe transaction is injected into the context; [Provider.GetExecutor] returns it\nautomatically so repository code requires no changes to participate.",
|
||
"file": "unit_of_work.go",
|
||
"line": 8,
|
||
"methods": [
|
||
{
|
||
"name": "Do",
|
||
"signature": "Do(ctx context.Context, fn func(ctx context.Context) error) error",
|
||
"doc": "Do begins a transaction, calls fn with the enriched context, and commits\non success or rolls back on error. The original fn error is always returned\nwhen fn fails, regardless of the rollback outcome."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewUnitOfWork",
|
||
"signature": "func NewUnitOfWork(logger logging.Logger, client Provider) UnitOfWork",
|
||
"doc": "NewUnitOfWork returns a UnitOfWork backed by the given client.",
|
||
"file": "new.go",
|
||
"line": 29
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "ctxTxKey",
|
||
"signature": "type ctxTxKey struct",
|
||
"doc": "ctxTxKey is the context key for the active transaction.",
|
||
"file": "new.go",
|
||
"line": 34
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "mysqlImpl",
|
||
"signature": "type mysqlImpl struct",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 38,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
},
|
||
{
|
||
"name": "cfg",
|
||
"type": "Config"
|
||
},
|
||
{
|
||
"name": "db",
|
||
"type": "*sql.DB"
|
||
},
|
||
{
|
||
"name": "mu",
|
||
"type": "sync.RWMutex"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlImpl.Begin",
|
||
"signature": "func (c *mysqlImpl) Begin(ctx context.Context) (Tx, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 135
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlImpl.BeginTx",
|
||
"signature": "func (c *mysqlImpl) BeginTx(ctx context.Context, opts *sql.TxOptions) (Tx, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 121
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlImpl.GetExecutor",
|
||
"signature": "func (c *mysqlImpl) GetExecutor(ctx context.Context) Executor",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 108
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlImpl.HandleError",
|
||
"signature": "func (c *mysqlImpl) HandleError(err error) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 149
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlImpl.HealthCheck",
|
||
"signature": "func (c *mysqlImpl) HealthCheck(ctx context.Context) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 96
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlImpl.ModulePath",
|
||
"signature": "func (c *mysqlImpl) ModulePath() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 7
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlImpl.ModuleVersion",
|
||
"signature": "func (c *mysqlImpl) ModuleVersion() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 9
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlImpl.Name",
|
||
"signature": "func (c *mysqlImpl) Name() string",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 93
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlImpl.OnInit",
|
||
"signature": "func (c *mysqlImpl) OnInit() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 45
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlImpl.OnStart",
|
||
"signature": "func (c *mysqlImpl) OnStart() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 72
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlImpl.OnStop",
|
||
"signature": "func (c *mysqlImpl) OnStop() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 82
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlImpl.Ping",
|
||
"signature": "func (c *mysqlImpl) Ping(ctx context.Context) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 98
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlImpl.Priority",
|
||
"signature": "func (c *mysqlImpl) Priority() observability.Level",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 94
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlImpl.Stats",
|
||
"signature": "func (c *mysqlImpl) Stats() sql.DBStats",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 139
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "mysqlTx",
|
||
"signature": "type mysqlTx struct",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 153,
|
||
"fields": [
|
||
{
|
||
"type": "*sql.Tx",
|
||
"embedded": true
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlTx.Commit",
|
||
"signature": "func (t *mysqlTx) Commit() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 167
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlTx.ExecContext",
|
||
"signature": "func (t *mysqlTx) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 155
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlTx.QueryContext",
|
||
"signature": "func (t *mysqlTx) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 159
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlTx.QueryRowContext",
|
||
"signature": "func (t *mysqlTx) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 163
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "mysqlTx.Rollback",
|
||
"signature": "func (t *mysqlTx) Rollback() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 168
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "unitOfWork",
|
||
"signature": "type unitOfWork struct",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 172,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
},
|
||
{
|
||
"name": "client",
|
||
"type": "Provider"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "unitOfWork.Do",
|
||
"signature": "func (u *unitOfWork) Do(ctx context.Context, fn func(ctx context.Context) error) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 177
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "HandleError",
|
||
"signature": "func HandleError(err error) error",
|
||
"doc": "HandleError maps MySQL driver and database/sql errors to typed [xerrors] values.\nReturns nil when err is nil. Also available as [Provider.HandleError].\n\nMapped codes:\n - 1062 (ER_DUP_ENTRY) → ErrAlreadyExists\n - 1216, 1217, 1451, 1452 (FK errors) → ErrInvalidInput\n - sql.ErrNoRows → ErrNotFound\n - all others → ErrInternal",
|
||
"file": "errors.go",
|
||
"line": 20
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "const",
|
||
"name": "modulePath",
|
||
"signature": "const modulePath = \"code.nochebuena.dev/einherjar/db-mysql\"",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 5
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"kind": "var",
|
||
"name": "_",
|
||
"signature": "var _ observability.Identifiable = (*mysqlImpl)(nil)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 18
|
||
}
|
||
],
|
||
"adrs": null,
|
||
"examples": [
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"title": "Setup",
|
||
"code": "import dbmysql \"code.nochebuena.dev/einherjar/db-mysql\"\n\ndb := dbmysql.New(logger, dbmysql.DefaultConfig())\nlc.Append(db) // OnInit opens pool; OnStop closes it\n// db is observability.Checkable (PING health check, LevelCritical):\nsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"title": "Querying",
|
||
"code": "// GetExecutor returns the pool when called outside a UnitOfWork.\nrows, err := db.GetExecutor(ctx).QueryContext(ctx, \"SELECT id, name FROM users WHERE active = ?\", true)\ndefer rows.Close()\n\nvar name string\nerr := db.GetExecutor(ctx).QueryRowContext(ctx, \"SELECT name FROM users WHERE id = ?\", id).Scan(\u0026name)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"title": "Manual transaction",
|
||
"code": "tx, err := db.Begin(ctx)\nif err != nil {\n return err\n}\ndefer tx.Rollback()\n\n_, err = tx.ExecContext(ctx, \"UPDATE accounts SET balance = balance - ? WHERE id = ?\", amount, fromID)\nif err != nil {\n return err\n}\nreturn tx.Commit()",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"title": "Manual transaction",
|
||
"code": "tx, err := db.BeginTx(ctx, \u0026sql.TxOptions{Isolation: sql.LevelSerializable})",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"title": "Unit of work (recommended)",
|
||
"code": "uow := dbmysql.NewUnitOfWork(logger, db)\n\nerr := uow.Do(ctx, func(ctx context.Context) error {\n _, err := db.GetExecutor(ctx).ExecContext(ctx, \"INSERT INTO orders (...) VALUES (...)\", ...)\n return err\n})",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"title": "Error handling",
|
||
"code": "if err := db.HandleError(someErr); err != nil {\n // MySQL error numbers mapped to xerrors:\n // 1062 ER_DUP_ENTRY → ErrAlreadyExists\n // 1216 ER_NO_REFERENCED_ROW → ErrPreconditionFailed\n // 1048 ER_BAD_NULL_ERROR → ErrInvalidInput\n // context.Canceled → ErrCancelled\n // context.DeadlineExceeded → ErrDeadlineExceeded\n}",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"title": "Dependency graph",
|
||
"code": "contracts (zero dependencies)\n ↑\n core\n ↑\ndb-mysql (contracts, core, go-sql-driver/mysql)\n ↑\n your app",
|
||
"language": ""
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"subPackage": "",
|
||
"title": "Verification",
|
||
"code": "cd db-mysql/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .",
|
||
"language": "bash"
|
||
}
|
||
],
|
||
"compliance": {
|
||
"interfaceAsserts": [
|
||
{
|
||
"module": "db-mysql",
|
||
"interface": "lifecycle.Component",
|
||
"impl": "(Component)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 24
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"interface": "observability.Checkable",
|
||
"impl": "(Component)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 25
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"interface": "Provider",
|
||
"impl": "(Component)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 26
|
||
}
|
||
],
|
||
"tests": [
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestAtMostOneExportedTypePerFile",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 30
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestDefaultConfig_OptionalFields",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 64
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestConfig_DSN",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 79
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestConfig_DSN_Defaults",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 92
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestHandleError_Nil",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 102
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestHandleError_DuplicateEntry",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 108
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestHandleError_ForeignKey_1452",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 112
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestHandleError_ForeignKey_1451",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 116
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestHandleError_ForeignKey_1216",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 120
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestHandleError_ForeignKey_1217",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 124
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestHandleError_NoRows",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 128
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestHandleError_Generic",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 132
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestNew_NotNil",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 138
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestComponent_Name",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 146
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestComponent_Priority_IsCritical",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 153
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestComponent_OnStop_NilDB",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 162
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestComponent_Begin_NilDB",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 169
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestComponent_BeginTx_NilDB",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 177
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestComponent_Stats_NilDB",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 185
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestComponent_GetExecutor_ReturnsNil",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 193
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestComponent_GetExecutor_ReturnsTx",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 201
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestUnitOfWork_Commit",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 213
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestUnitOfWork_Rollback",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 224
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestUnitOfWork_InjectsExecutor",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 233
|
||
},
|
||
{
|
||
"module": "db-mysql",
|
||
"name": "TestUnitOfWork_ReturnsBeginError",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 247
|
||
}
|
||
]
|
||
},
|
||
"readme": "# einherjar/db-mysql\n\n[](https://code.nochebuena.dev/einherjar/db-mysql)\n[](LICENSE)\n[](https://go.dev)\n[]()\n\n\u003e A warrior's deeds are committed to stone so that what they built survives the builder.\n\n`code.nochebuena.dev/einherjar/db-mysql` is the MySQL/MariaDB database component of the Einherjar framework. It wraps `database/sql` with `go-sql-driver/mysql` behind a lifecycle-aware `Component`, exposes a uniform `Executor` interface for queries and transactions, and provides a `UnitOfWork` that injects the active transaction into context — so repositories never need to know whether they are inside a transaction or not.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport dbmysql \"code.nochebuena.dev/einherjar/db-mysql\"\n\ndb := dbmysql.New(logger, dbmysql.DefaultConfig())\nlc.Append(db) // OnInit opens pool; OnStop closes it\n// db is observability.Checkable (PING health check, LevelCritical):\nsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)\n```\n\n### Querying\n\n```go\n// GetExecutor returns the pool when called outside a UnitOfWork.\nrows, err := db.GetExecutor(ctx).QueryContext(ctx, \"SELECT id, name FROM users WHERE active = ?\", true)\ndefer rows.Close()\n\nvar name string\nerr := db.GetExecutor(ctx).QueryRowContext(ctx, \"SELECT name FROM users WHERE id = ?\", id).Scan(\u0026name)\n```\n\n### Manual transaction\n\n```go\ntx, err := db.Begin(ctx)\nif err != nil {\n return err\n}\ndefer tx.Rollback()\n\n_, err = tx.ExecContext(ctx, \"UPDATE accounts SET balance = balance - ? WHERE id = ?\", amount, fromID)\nif err != nil {\n return err\n}\nreturn tx.Commit()\n```\n\n`BeginTx` is available when you need explicit isolation level control:\n\n```go\ntx, err := db.BeginTx(ctx, \u0026sql.TxOptions{Isolation: sql.LevelSerializable})\n```\n\n### Unit of work (recommended)\n\n`UnitOfWork` wraps the transaction in context so every call to `GetExecutor` inside `uow.Do` automatically returns the active transaction. Repositories require no changes.\n\n```go\nuow := dbmysql.NewUnitOfWork(logger, db)\n\nerr := uow.Do(ctx, func(ctx context.Context) error {\n _, err := db.GetExecutor(ctx).ExecContext(ctx, \"INSERT INTO orders (...) VALUES (...)\", ...)\n return err\n})\n```\n\nIf the function returns an error, the transaction is rolled back. If it returns nil, the transaction is committed.\n\n### Error handling\n\n```go\nif err := db.HandleError(someErr); err != nil {\n // MySQL error numbers mapped to xerrors:\n // 1062 ER_DUP_ENTRY → ErrAlreadyExists\n // 1216 ER_NO_REFERENCED_ROW → ErrPreconditionFailed\n // 1048 ER_BAD_NULL_ERROR → ErrInvalidInput\n // context.Canceled → ErrCancelled\n // context.DeadlineExceeded → ErrDeadlineExceeded\n}\n```\n\n`HandleError` is also available as a package-level function: `dbmysql.HandleError(err)`.\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_MYSQL_HOST` | Yes | — | MySQL host |\n| `EINHERJAR_MYSQL_PORT` | No | `3306` | Listen port |\n| `EINHERJAR_MYSQL_USER` | Yes | — | Database user |\n| `EINHERJAR_MYSQL_PASSWORD` | Yes | — | Database password |\n| `EINHERJAR_MYSQL_NAME` | Yes | — | Database name |\n| `EINHERJAR_MYSQL_MAX_CONNS` | No | `5` | Maximum open connections |\n| `EINHERJAR_MYSQL_MIN_CONNS` | No | `2` | Minimum idle connections |\n| `EINHERJAR_MYSQL_MAX_CONN_LIFETIME` | No | `1h` | Maximum connection lifetime |\n| `EINHERJAR_MYSQL_MAX_CONN_IDLE_TIME` | No | `30m` | Maximum idle time before connection is closed |\n| `EINHERJAR_MYSQL_CHARSET` | No | `utf8mb4` | Connection charset |\n| `EINHERJAR_MYSQL_LOC` | No | `UTC` | Timezone location |\n| `EINHERJAR_MYSQL_PARSE_TIME` | No | `true` | Parse `DATE`/`DATETIME` as `time.Time` |\n\n\u003e **Note:** Set collation at the schema level (DDL), not in the DSN. MariaDB 11.4+ collation names exceed the 1-byte handshake limit in `go-sql-driver`.\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\ndb-mysql (contracts, core, go-sql-driver/mysql)\n ↑\n your app\n```\n\n---\n\n## Verification\n\n```bash\ncd db-mysql/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *The hall is built before the warriors arrive.*\n\u003e *That is the only guarantee worth making.*\n",
|
||
"changelog": "# Changelog — einherjar/db-mysql\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n"
|
||
},
|
||
{
|
||
"name": "db-postgres",
|
||
"importPath": "code.nochebuena.dev/einherjar/db-postgres",
|
||
"purpose": "The runes carved in stone do not fade when the runemaster dies. They outlast the hand that carved them.",
|
||
"doc": "Package postgres provides a pgx-backed PostgreSQL client with lifecycle management,\nhealth checks, and unit-of-work transaction support for Einherjar applications.\n\n# Overview\n\n[New] returns a [Component] that manages a [pgxpool.Pool] connection pool,\nsatisfies the [lifecycle.Component] lifecycle hooks (OnInit, OnStart, OnStop),\nand implements [observability.Checkable] with critical priority.\n\n[NewUnitOfWork] wraps multiple repository operations in a single transaction\nvia context injection — no transaction object is passed between functions.\n\n# Lifecycle Registration\n\n\tdb := postgres.New(logger, cfg)\n\tlc.Append(db) // lifecycle: OnInit → OnStart → OnStop\n\ndb satisfies [observability.Checkable], so it can back a health endpoint via\nweb/health.NewHandler:\n\n\tsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)\n\n# Querying\n\nRepository code receives a [Provider] or [Executor] and calls [Provider.GetExecutor]\nto obtain the active transaction (if inside a [UnitOfWork]) or the pool:\n\n\texec := db.GetExecutor(ctx)\n\trow := exec.QueryRow(ctx, \"SELECT id FROM users WHERE email = $1\", email)\n\tif err := row.Scan(\u0026id); err != nil {\n\t return db.HandleError(err) // maps pgx.ErrNoRows → ErrNotFound, etc.\n\t}\n\n# Unit of Work\n\n[NewUnitOfWork] wraps operations in a single transaction. The transaction is\ninjected into the context; [Provider.GetExecutor] returns it automatically.\n\n\tuow := postgres.NewUnitOfWork(logger, db)\n\terr := uow.Do(ctx, func(ctx context.Context) error {\n\t exec := db.GetExecutor(ctx) // returns active Tx\n\t _, err := exec.Exec(ctx, \"INSERT INTO orders ...\")\n\t return err\n\t})\n\n# Error Handling\n\n[HandleError] translates pgx and PostgreSQL error codes into typed\n[xerrors] values. Call it at every point where a pgx error is first observed:\n\n\tif err := row.Scan(\u0026out); err != nil {\n\t return db.HandleError(err)\n\t}\n\nMapped codes:\n - UniqueViolation → ErrAlreadyExists\n - ForeignKeyViolation → ErrInvalidInput\n - CheckViolation → ErrInvalidInput\n - pgx.ErrNoRows → ErrNotFound\n - all others → ErrInternal",
|
||
"goVersion": "1.26",
|
||
"dependsOn": [
|
||
"contracts",
|
||
"core"
|
||
],
|
||
"subPackages": [
|
||
{
|
||
"name": "",
|
||
"importPath": "code.nochebuena.dev/einherjar/db-postgres",
|
||
"doc": "Package postgres provides a pgx-backed PostgreSQL client with lifecycle management,\nhealth checks, and unit-of-work transaction support for Einherjar applications.\n\n# Overview\n\n[New] returns a [Component] that manages a [pgxpool.Pool] connection pool,\nsatisfies the [lifecycle.Component] lifecycle hooks (OnInit, OnStart, OnStop),\nand implements [observability.Checkable] with critical priority.\n\n[NewUnitOfWork] wraps multiple repository operations in a single transaction\nvia context injection — no transaction object is passed between functions.\n\n# Lifecycle Registration\n\n\tdb := postgres.New(logger, cfg)\n\tlc.Append(db) // lifecycle: OnInit → OnStart → OnStop\n\ndb satisfies [observability.Checkable], so it can back a health endpoint via\nweb/health.NewHandler:\n\n\tsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)\n\n# Querying\n\nRepository code receives a [Provider] or [Executor] and calls [Provider.GetExecutor]\nto obtain the active transaction (if inside a [UnitOfWork]) or the pool:\n\n\texec := db.GetExecutor(ctx)\n\trow := exec.QueryRow(ctx, \"SELECT id FROM users WHERE email = $1\", email)\n\tif err := row.Scan(\u0026id); err != nil {\n\t return db.HandleError(err) // maps pgx.ErrNoRows → ErrNotFound, etc.\n\t}\n\n# Unit of Work\n\n[NewUnitOfWork] wraps operations in a single transaction. The transaction is\ninjected into the context; [Provider.GetExecutor] returns it automatically.\n\n\tuow := postgres.NewUnitOfWork(logger, db)\n\terr := uow.Do(ctx, func(ctx context.Context) error {\n\t exec := db.GetExecutor(ctx) // returns active Tx\n\t _, err := exec.Exec(ctx, \"INSERT INTO orders ...\")\n\t return err\n\t})\n\n# Error Handling\n\n[HandleError] translates pgx and PostgreSQL error codes into typed\n[xerrors] values. Call it at every point where a pgx error is first observed:\n\n\tif err := row.Scan(\u0026out); err != nil {\n\t return db.HandleError(err)\n\t}\n\nMapped codes:\n - UniqueViolation → ErrAlreadyExists\n - ForeignKeyViolation → ErrInvalidInput\n - CheckViolation → ErrInvalidInput\n - pgx.ErrNoRows → ErrNotFound\n - all others → ErrInternal"
|
||
}
|
||
],
|
||
"symbols": [
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Component",
|
||
"signature": "type Component interface",
|
||
"doc": "Component bundles the full postgres capability: lifecycle management,\nhealth reporting, and the database [Provider] interface.\nRegister with launcher and health before starting.",
|
||
"file": "component.go",
|
||
"line": 12,
|
||
"methods": [
|
||
{
|
||
"signature": "lifecycle.Component"
|
||
},
|
||
{
|
||
"signature": "observability.Checkable"
|
||
},
|
||
{
|
||
"signature": "observability.Identifiable"
|
||
},
|
||
{
|
||
"signature": "Provider"
|
||
},
|
||
{
|
||
"name": "Stats",
|
||
"signature": "Stats() *pgxpool.Stat",
|
||
"doc": "Stats returns connection pool statistics. Returns a zero-value stat\nwhen the pool has not been initialized yet."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "New",
|
||
"signature": "func New(logger logging.Logger, cfg Config) Component",
|
||
"doc": "New returns a Component backed by the given configuration.\nThe pool is not created until OnInit is called.",
|
||
"file": "new.go",
|
||
"line": 26
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "Config",
|
||
"signature": "type Config struct",
|
||
"doc": "Config holds PostgreSQL connection settings. Required fields must be supplied\nby the caller; optional fields have production-safe defaults via [DefaultConfig].",
|
||
"file": "config.go",
|
||
"line": 10,
|
||
"fields": [
|
||
{
|
||
"name": "Host",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_PG_HOST,required\""
|
||
},
|
||
{
|
||
"name": "Port",
|
||
"type": "int",
|
||
"tag": "env:\"EINHERJAR_PG_PORT\" envDefault:\"5432\""
|
||
},
|
||
{
|
||
"name": "User",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_PG_USER,required\""
|
||
},
|
||
{
|
||
"name": "Password",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_PG_PASSWORD,required\""
|
||
},
|
||
{
|
||
"name": "Name",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_PG_NAME,required\""
|
||
},
|
||
{
|
||
"name": "SSLMode",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_PG_SSL_MODE\" envDefault:\"disable\""
|
||
},
|
||
{
|
||
"name": "Timezone",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_PG_TIMEZONE\" envDefault:\"UTC\""
|
||
},
|
||
{
|
||
"name": "MaxConns",
|
||
"type": "int",
|
||
"tag": "env:\"EINHERJAR_PG_MAX_CONNS\" envDefault:\"5\""
|
||
},
|
||
{
|
||
"name": "MinConns",
|
||
"type": "int",
|
||
"tag": "env:\"EINHERJAR_PG_MIN_CONNS\" envDefault:\"2\""
|
||
},
|
||
{
|
||
"name": "MaxConnLifetime",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_PG_MAX_CONN_LIFETIME\" envDefault:\"1h\""
|
||
},
|
||
{
|
||
"name": "MaxConnIdleTime",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_PG_MAX_CONN_IDLE_TIME\" envDefault:\"30m\""
|
||
},
|
||
{
|
||
"name": "HealthCheckPeriod",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_PG_HEALTH_CHECK_PERIOD\" envDefault:\"1m\""
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "DefaultConfig",
|
||
"signature": "func DefaultConfig() Config",
|
||
"doc": "DefaultConfig returns a Config with all optional fields set to production-safe\ndefaults. Callers must supply Host, Port, User, Password, and Name.",
|
||
"file": "config.go",
|
||
"line": 27
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "Config.DSN",
|
||
"signature": "func (c Config) DSN() string",
|
||
"doc": "DSN constructs a PostgreSQL connection string from the configuration.",
|
||
"file": "config.go",
|
||
"line": 41
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Executor",
|
||
"signature": "type Executor interface",
|
||
"doc": "Executor is the shared query interface for both the connection pool and\nan active transaction. Repository code accepts Executor so it works\nidentically inside and outside a [UnitOfWork].",
|
||
"file": "executor.go",
|
||
"line": 13,
|
||
"methods": [
|
||
{
|
||
"name": "Exec",
|
||
"signature": "Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)",
|
||
"doc": "Exec executes a query that returns no rows."
|
||
},
|
||
{
|
||
"name": "Query",
|
||
"signature": "Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)",
|
||
"doc": "Query executes a query that returns rows."
|
||
},
|
||
{
|
||
"name": "QueryRow",
|
||
"signature": "QueryRow(ctx context.Context, sql string, args ...any) pgx.Row",
|
||
"doc": "QueryRow executes a query that returns at most one row."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Provider",
|
||
"signature": "type Provider interface",
|
||
"doc": "Provider is the database access interface consumed by repositories and services.\nAll methods are safe for concurrent use.",
|
||
"file": "provider.go",
|
||
"line": 11,
|
||
"methods": [
|
||
{
|
||
"name": "GetExecutor",
|
||
"signature": "GetExecutor(ctx context.Context) Executor",
|
||
"doc": "GetExecutor returns the active transaction injected by [UnitOfWork] if one\nis present in ctx, otherwise returns the connection pool."
|
||
},
|
||
{
|
||
"name": "Begin",
|
||
"signature": "Begin(ctx context.Context) (Tx, error)",
|
||
"doc": "Begin starts a new transaction with default options."
|
||
},
|
||
{
|
||
"name": "BeginTx",
|
||
"signature": "BeginTx(ctx context.Context, opts pgx.TxOptions) (Tx, error)",
|
||
"doc": "BeginTx starts a new transaction with the given options."
|
||
},
|
||
{
|
||
"name": "Ping",
|
||
"signature": "Ping(ctx context.Context) error",
|
||
"doc": "Ping verifies that the database connection is alive."
|
||
},
|
||
{
|
||
"name": "HandleError",
|
||
"signature": "HandleError(err error) error",
|
||
"doc": "HandleError maps a pgx or PostgreSQL error to a typed [xerrors] value.\nCall this at every point where a pgx error is first observed."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Tx",
|
||
"signature": "type Tx interface",
|
||
"doc": "Tx extends [Executor] with commit and rollback. Obtained via [Provider.Begin]\nor [Provider.BeginTx] when manual transaction control is needed.\nPrefer [UnitOfWork] for the common case of a single transactional unit.",
|
||
"file": "tx.go",
|
||
"line": 8,
|
||
"methods": [
|
||
{
|
||
"signature": "Executor"
|
||
},
|
||
{
|
||
"name": "Commit",
|
||
"signature": "Commit(ctx context.Context) error",
|
||
"doc": "Commit commits the transaction."
|
||
},
|
||
{
|
||
"name": "Rollback",
|
||
"signature": "Rollback(ctx context.Context) error",
|
||
"doc": "Rollback rolls back the transaction. Safe to call after Commit."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "UnitOfWork",
|
||
"signature": "type UnitOfWork interface",
|
||
"doc": "UnitOfWork wraps a set of repository operations in a single database transaction.\nThe transaction is injected into the context; [Provider.GetExecutor] returns it\nautomatically so repository code requires no changes to participate.",
|
||
"file": "unit_of_work.go",
|
||
"line": 8,
|
||
"methods": [
|
||
{
|
||
"name": "Do",
|
||
"signature": "Do(ctx context.Context, fn func(ctx context.Context) error) error",
|
||
"doc": "Do begins a transaction, calls fn with the enriched context, and commits\non success or rolls back on error. The original fn error is always returned\nwhen fn fails, regardless of the rollback outcome."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewUnitOfWork",
|
||
"signature": "func NewUnitOfWork(logger logging.Logger, client Provider) UnitOfWork",
|
||
"doc": "NewUnitOfWork returns a UnitOfWork backed by the given client.",
|
||
"file": "new.go",
|
||
"line": 31
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "ctxTxKey",
|
||
"signature": "type ctxTxKey struct",
|
||
"doc": "ctxTxKey is the context key for the active transaction.",
|
||
"file": "new.go",
|
||
"line": 36
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "pgComponent",
|
||
"signature": "type pgComponent struct",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 40,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
},
|
||
{
|
||
"name": "cfg",
|
||
"type": "Config"
|
||
},
|
||
{
|
||
"name": "pool",
|
||
"type": "*pgxpool.Pool"
|
||
},
|
||
{
|
||
"name": "mu",
|
||
"type": "sync.RWMutex"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.Begin",
|
||
"signature": "func (c *pgComponent) Begin(ctx context.Context) (Tx, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 120
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.BeginTx",
|
||
"signature": "func (c *pgComponent) BeginTx(ctx context.Context, opts pgx.TxOptions) (Tx, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 106
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.Exec",
|
||
"signature": "func (c *pgComponent) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 134
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.GetExecutor",
|
||
"signature": "func (c *pgComponent) GetExecutor(ctx context.Context) Executor",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 96
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.HandleError",
|
||
"signature": "func (c *pgComponent) HandleError(err error) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 155
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.HealthCheck",
|
||
"signature": "func (c *pgComponent) HealthCheck(ctx context.Context) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 157
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.ModulePath",
|
||
"signature": "func (c *pgComponent) ModulePath() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 7
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.ModuleVersion",
|
||
"signature": "func (c *pgComponent) ModuleVersion() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 9
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.Name",
|
||
"signature": "func (c *pgComponent) Name() string",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 158
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.OnInit",
|
||
"signature": "func (c *pgComponent) OnInit() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 47
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.OnStart",
|
||
"signature": "func (c *pgComponent) OnStart() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 65
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.OnStop",
|
||
"signature": "func (c *pgComponent) OnStop() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 75
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.Ping",
|
||
"signature": "func (c *pgComponent) Ping(ctx context.Context) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 86
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.Priority",
|
||
"signature": "func (c *pgComponent) Priority() observability.Level",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 159
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.Query",
|
||
"signature": "func (c *pgComponent) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 141
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.QueryRow",
|
||
"signature": "func (c *pgComponent) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 148
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.Stats",
|
||
"signature": "func (c *pgComponent) Stats() *pgxpool.Stat",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 124
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgComponent.buildPoolConfig",
|
||
"signature": "func (c *pgComponent) buildPoolConfig() (*pgxpool.Config, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 161
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "pgTx",
|
||
"signature": "type pgTx struct",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 194,
|
||
"fields": [
|
||
{
|
||
"type": "pgx.Tx",
|
||
"embedded": true
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgTx.Commit",
|
||
"signature": "func (t *pgTx) Commit(ctx context.Context) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 208
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgTx.Exec",
|
||
"signature": "func (t *pgTx) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 196
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgTx.Query",
|
||
"signature": "func (t *pgTx) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 200
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgTx.QueryRow",
|
||
"signature": "func (t *pgTx) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 204
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "pgTx.Rollback",
|
||
"signature": "func (t *pgTx) Rollback(ctx context.Context) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 209
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "unitOfWork",
|
||
"signature": "type unitOfWork struct",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 213,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
},
|
||
{
|
||
"name": "client",
|
||
"type": "Provider"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "unitOfWork.Do",
|
||
"signature": "func (u *unitOfWork) Do(ctx context.Context, fn func(ctx context.Context) error) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 218
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "HandleError",
|
||
"signature": "func HandleError(err error) error",
|
||
"doc": "HandleError maps pgx and PostgreSQL errors to typed [xerrors] values.\nReturns nil when err is nil. Also available as [Provider.HandleError].\n\nMapped codes:\n - UniqueViolation → ErrAlreadyExists\n - ForeignKeyViolation → ErrInvalidInput\n - CheckViolation → ErrInvalidInput\n - pgx.ErrNoRows → ErrNotFound\n - all others → ErrInternal",
|
||
"file": "errors.go",
|
||
"line": 22
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "const",
|
||
"name": "modulePath",
|
||
"signature": "const modulePath = \"code.nochebuena.dev/einherjar/db-postgres\"",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 5
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"kind": "var",
|
||
"name": "_",
|
||
"signature": "var _ observability.Identifiable = (*pgComponent)(nil)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 20
|
||
}
|
||
],
|
||
"adrs": null,
|
||
"examples": [
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"title": "Setup",
|
||
"code": "import dbpg \"code.nochebuena.dev/einherjar/db-postgres\"\n\ndb := dbpg.New(logger, dbpg.DefaultConfig())\nlc.Append(db) // OnInit opens pool; OnStop closes it\n// db is observability.Checkable (PING health check, LevelCritical):\nsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"title": "Querying",
|
||
"code": "// GetExecutor returns the pool when called outside a UnitOfWork.\nrows, err := db.GetExecutor(ctx).Query(ctx, \"SELECT id, name FROM users WHERE active = $1\", true)\ndefer rows.Close()\n\nvar name string\nerr := db.GetExecutor(ctx).QueryRow(ctx, \"SELECT name FROM users WHERE id = $1\", id).Scan(\u0026name)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"title": "Manual transaction",
|
||
"code": "tx, err := db.Begin(ctx)\nif err != nil {\n return err\n}\ndefer tx.Rollback(ctx)\n\n_, err = tx.Exec(ctx, \"UPDATE accounts SET balance = balance - $1 WHERE id = $2\", amount, fromID)\nif err != nil {\n return err\n}\nreturn tx.Commit(ctx)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"title": "Manual transaction",
|
||
"code": "tx, err := db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"title": "Unit of work (recommended)",
|
||
"code": "uow := dbpg.NewUnitOfWork(logger, db)\n\nerr := uow.Do(ctx, func(ctx context.Context) error {\n _, err := db.GetExecutor(ctx).Exec(ctx, \"INSERT INTO orders (...) VALUES (...)\", ...)\n return err\n})",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"title": "Error handling",
|
||
"code": "if err := db.HandleError(someErr); err != nil {\n // pgconn error codes mapped to xerrors:\n // 23505 unique_violation → ErrAlreadyExists\n // 23503 foreign_key_violation → ErrPreconditionFailed\n // 23502 not_null_violation → ErrInvalidInput\n // context.Canceled → ErrCancelled\n // context.DeadlineExceeded → ErrDeadlineExceeded\n}",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"title": "Dependency graph",
|
||
"code": "contracts (zero dependencies)\n ↑\n core\n ↑\ndb-postgres (contracts, core, pgx/v5, pgerrcode)\n ↑\n your app",
|
||
"language": ""
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"subPackage": "",
|
||
"title": "Verification",
|
||
"code": "cd db-postgres/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .",
|
||
"language": "bash"
|
||
}
|
||
],
|
||
"compliance": {
|
||
"interfaceAsserts": [
|
||
{
|
||
"module": "db-postgres",
|
||
"interface": "lifecycle.Component",
|
||
"impl": "(Component)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 25
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"interface": "observability.Checkable",
|
||
"impl": "(Component)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 26
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"interface": "Provider",
|
||
"impl": "(Component)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 27
|
||
}
|
||
],
|
||
"tests": [
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestAtMostOneExportedTypePerFile",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 31
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestDefaultConfig_OptionalFields",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 65
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestConfig_DSN",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 95
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestHandleError_Nil",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 111
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestHandleError_UniqueViolation",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 117
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestHandleError_ForeignKey",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 121
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestHandleError_CheckViolation",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 125
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestHandleError_NoRows",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 129
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestHandleError_Generic",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 133
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestNew_NotNil",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 139
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestComponent_Name",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 145
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestComponent_Priority",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 152
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestComponent_Stats_BeforeInit",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 159
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestComponent_OnStop_NilPool",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 166
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestComponent_BeginTx_NilPool",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 173
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestUnitOfWork_Commit",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 183
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestUnitOfWork_Rollback",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 194
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestUnitOfWork_InjectsExecutor",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 203
|
||
},
|
||
{
|
||
"module": "db-postgres",
|
||
"name": "TestUnitOfWork_ReturnsBeginError",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 217
|
||
}
|
||
]
|
||
},
|
||
"readme": "# einherjar/db-postgres\n\n[](https://code.nochebuena.dev/einherjar/db-postgres)\n[](LICENSE)\n[](https://go.dev)\n[]()\n\n\u003e The runes carved in stone do not fade when the runemaster dies. They outlast the hand that carved them.\n\n`code.nochebuena.dev/einherjar/db-postgres` is the PostgreSQL database component of the Einherjar framework. It wraps `pgxpool` behind a lifecycle-aware `Component`, exposes a uniform `Executor` interface for queries and transactions, and provides a `UnitOfWork` that injects the active transaction into context — so repositories never need to know whether they are inside a transaction or not.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport dbpg \"code.nochebuena.dev/einherjar/db-postgres\"\n\ndb := dbpg.New(logger, dbpg.DefaultConfig())\nlc.Append(db) // OnInit opens pool; OnStop closes it\n// db is observability.Checkable (PING health check, LevelCritical):\nsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)\n```\n\n### Querying\n\n```go\n// GetExecutor returns the pool when called outside a UnitOfWork.\nrows, err := db.GetExecutor(ctx).Query(ctx, \"SELECT id, name FROM users WHERE active = $1\", true)\ndefer rows.Close()\n\nvar name string\nerr := db.GetExecutor(ctx).QueryRow(ctx, \"SELECT name FROM users WHERE id = $1\", id).Scan(\u0026name)\n```\n\n### Manual transaction\n\n```go\ntx, err := db.Begin(ctx)\nif err != nil {\n return err\n}\ndefer tx.Rollback(ctx)\n\n_, err = tx.Exec(ctx, \"UPDATE accounts SET balance = balance - $1 WHERE id = $2\", amount, fromID)\nif err != nil {\n return err\n}\nreturn tx.Commit(ctx)\n```\n\n`BeginTx` is available when you need explicit isolation level control:\n\n```go\ntx, err := db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})\n```\n\n### Unit of work (recommended)\n\n`UnitOfWork` wraps the transaction in context so every call to `GetExecutor` inside `uow.Do` automatically returns the active transaction. Repositories require no changes.\n\n```go\nuow := dbpg.NewUnitOfWork(logger, db)\n\nerr := uow.Do(ctx, func(ctx context.Context) error {\n _, err := db.GetExecutor(ctx).Exec(ctx, \"INSERT INTO orders (...) VALUES (...)\", ...)\n return err\n})\n```\n\nIf the function returns an error, the transaction is rolled back. If it returns nil, the transaction is committed.\n\n### Error handling\n\n```go\nif err := db.HandleError(someErr); err != nil {\n // pgconn error codes mapped to xerrors:\n // 23505 unique_violation → ErrAlreadyExists\n // 23503 foreign_key_violation → ErrPreconditionFailed\n // 23502 not_null_violation → ErrInvalidInput\n // context.Canceled → ErrCancelled\n // context.DeadlineExceeded → ErrDeadlineExceeded\n}\n```\n\n`HandleError` is also available as a package-level function: `dbpg.HandleError(err)`.\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_PG_HOST` | Yes | — | PostgreSQL host |\n| `EINHERJAR_PG_PORT` | No | `5432` | Listen port |\n| `EINHERJAR_PG_USER` | Yes | — | Database user |\n| `EINHERJAR_PG_PASSWORD` | Yes | — | Database password |\n| `EINHERJAR_PG_NAME` | Yes | — | Database name |\n| `EINHERJAR_PG_SSL_MODE` | No | `disable` | `disable`, `require`, `verify-full` |\n| `EINHERJAR_PG_TIMEZONE` | No | `UTC` | Session timezone |\n| `EINHERJAR_PG_MAX_CONNS` | No | `5` | Maximum connections in pool |\n| `EINHERJAR_PG_MIN_CONNS` | No | `2` | Minimum idle connections |\n| `EINHERJAR_PG_MAX_CONN_LIFETIME` | No | `1h` | Maximum connection lifetime |\n| `EINHERJAR_PG_MAX_CONN_IDLE_TIME` | No | `30m` | Maximum idle time before connection is closed |\n| `EINHERJAR_PG_HEALTH_CHECK_PERIOD` | No | `1m` | pgxpool background health check interval |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\ndb-postgres (contracts, core, pgx/v5, pgerrcode)\n ↑\n your app\n```\n\n---\n\n## Verification\n\n```bash\ncd db-postgres/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *The hall is built before the warriors arrive.*\n\u003e *That is the only guarantee worth making.*\n",
|
||
"changelog": "# Changelog — einherjar/db-postgres\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n"
|
||
},
|
||
{
|
||
"name": "db-sqlite",
|
||
"importPath": "code.nochebuena.dev/einherjar/db-sqlite",
|
||
"purpose": "Not every hall needs pillars that reach the sky. Sometimes what matters fits in a single room, carried wherever the warrior goes.",
|
||
"doc": "Package sqlite provides a pure-Go SQLite client with lifecycle management,\nhealth checks, and unit-of-work transaction support for Einherjar applications.\n\n# Overview\n\n[New] returns a [Component] that manages a [database/sql] connection pool,\nsatisfies the [lifecycle.Component] lifecycle hooks (OnInit, OnStart, OnStop),\nand implements [observability.Checkable] with critical priority.\n\n[NewUnitOfWork] wraps multiple repository operations in a single serialized\ntransaction via context injection — no transaction object is passed between\nfunctions. Write transactions are serialized through an internal mutex to\nprevent SQLITE_BUSY errors under concurrent goroutines.\n\n# Lifecycle Registration\n\n\tdb := sqlite.New(logger, cfg)\n\tlc.Append(db) // lifecycle: OnInit → OnStart → OnStop\n\ndb satisfies [observability.Checkable], so it can back a health endpoint via\nweb/health.NewHandler:\n\n\tsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)\n\n# Querying\n\nRepository code receives a [Provider] or [Executor] and calls [Provider.GetExecutor]\nto obtain the active transaction (if inside a [UnitOfWork]) or the connection pool:\n\n\texec := db.GetExecutor(ctx)\n\trow := exec.QueryRowContext(ctx, \"SELECT id FROM users WHERE email = ?\", email)\n\tif err := row.Scan(\u0026id); err != nil {\n\t return db.HandleError(err) // maps sql.ErrNoRows → ErrNotFound, etc.\n\t}\n\n# Unit of Work\n\n[NewUnitOfWork] wraps operations in a single serialized write transaction.\nThe transaction is injected into the context; [Provider.GetExecutor] returns\nit automatically.\n\n\tuow := sqlite.NewUnitOfWork(logger, db)\n\terr := uow.Do(ctx, func(ctx context.Context) error {\n\t exec := db.GetExecutor(ctx) // returns active Tx\n\t _, err := exec.ExecContext(ctx, \"INSERT INTO orders ...\")\n\t return err\n\t})\n\n# Error Handling\n\n[HandleError] translates database/sql and SQLite error codes into typed\n[xerrors] values. Call it at every point where a database error is first observed:\n\n\tif err := row.Scan(\u0026out); err != nil {\n\t return db.HandleError(err)\n\t}\n\nMapped codes:\n - sql.ErrNoRows → ErrNotFound\n - SQLITE_CONSTRAINT_UNIQUE (2067) → ErrAlreadyExists\n - SQLITE_CONSTRAINT_PRIMARYKEY (1555) → ErrAlreadyExists\n - SQLITE_CONSTRAINT_FOREIGNKEY (787) → ErrInvalidInput\n - all others → ErrInternal\n\n# Configuration\n\nAll fields are read from environment variables with the EINHERJAR_SQLITE_* prefix:\n\n - EINHERJAR_SQLITE_PATH (required) — file path or \":memory:\"\n - EINHERJAR_SQLITE_MAX_OPEN_CONNS — default: 1\n - EINHERJAR_SQLITE_MAX_IDLE_CONNS — default: 1\n - EINHERJAR_SQLITE_PRAGMAS — default: \"?_journal=WAL\u0026_timeout=5000\u0026_fk=true\"",
|
||
"goVersion": "1.26",
|
||
"dependsOn": [
|
||
"contracts",
|
||
"core"
|
||
],
|
||
"subPackages": [
|
||
{
|
||
"name": "",
|
||
"importPath": "code.nochebuena.dev/einherjar/db-sqlite",
|
||
"doc": "Package sqlite provides a pure-Go SQLite client with lifecycle management,\nhealth checks, and unit-of-work transaction support for Einherjar applications.\n\n# Overview\n\n[New] returns a [Component] that manages a [database/sql] connection pool,\nsatisfies the [lifecycle.Component] lifecycle hooks (OnInit, OnStart, OnStop),\nand implements [observability.Checkable] with critical priority.\n\n[NewUnitOfWork] wraps multiple repository operations in a single serialized\ntransaction via context injection — no transaction object is passed between\nfunctions. Write transactions are serialized through an internal mutex to\nprevent SQLITE_BUSY errors under concurrent goroutines.\n\n# Lifecycle Registration\n\n\tdb := sqlite.New(logger, cfg)\n\tlc.Append(db) // lifecycle: OnInit → OnStart → OnStop\n\ndb satisfies [observability.Checkable], so it can back a health endpoint via\nweb/health.NewHandler:\n\n\tsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)\n\n# Querying\n\nRepository code receives a [Provider] or [Executor] and calls [Provider.GetExecutor]\nto obtain the active transaction (if inside a [UnitOfWork]) or the connection pool:\n\n\texec := db.GetExecutor(ctx)\n\trow := exec.QueryRowContext(ctx, \"SELECT id FROM users WHERE email = ?\", email)\n\tif err := row.Scan(\u0026id); err != nil {\n\t return db.HandleError(err) // maps sql.ErrNoRows → ErrNotFound, etc.\n\t}\n\n# Unit of Work\n\n[NewUnitOfWork] wraps operations in a single serialized write transaction.\nThe transaction is injected into the context; [Provider.GetExecutor] returns\nit automatically.\n\n\tuow := sqlite.NewUnitOfWork(logger, db)\n\terr := uow.Do(ctx, func(ctx context.Context) error {\n\t exec := db.GetExecutor(ctx) // returns active Tx\n\t _, err := exec.ExecContext(ctx, \"INSERT INTO orders ...\")\n\t return err\n\t})\n\n# Error Handling\n\n[HandleError] translates database/sql and SQLite error codes into typed\n[xerrors] values. Call it at every point where a database error is first observed:\n\n\tif err := row.Scan(\u0026out); err != nil {\n\t return db.HandleError(err)\n\t}\n\nMapped codes:\n - sql.ErrNoRows → ErrNotFound\n - SQLITE_CONSTRAINT_UNIQUE (2067) → ErrAlreadyExists\n - SQLITE_CONSTRAINT_PRIMARYKEY (1555) → ErrAlreadyExists\n - SQLITE_CONSTRAINT_FOREIGNKEY (787) → ErrInvalidInput\n - all others → ErrInternal\n\n# Configuration\n\nAll fields are read from environment variables with the EINHERJAR_SQLITE_* prefix:\n\n - EINHERJAR_SQLITE_PATH (required) — file path or \":memory:\"\n - EINHERJAR_SQLITE_MAX_OPEN_CONNS — default: 1\n - EINHERJAR_SQLITE_MAX_IDLE_CONNS — default: 1\n - EINHERJAR_SQLITE_PRAGMAS — default: \"?_journal=WAL\u0026_timeout=5000\u0026_fk=true\""
|
||
}
|
||
],
|
||
"symbols": [
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Component",
|
||
"signature": "type Component interface",
|
||
"doc": "Component bundles the full sqlite capability: lifecycle management,\nhealth reporting, and the database [Provider] interface.\nRegister with launcher and health before starting.",
|
||
"file": "component.go",
|
||
"line": 11,
|
||
"methods": [
|
||
{
|
||
"signature": "lifecycle.Component"
|
||
},
|
||
{
|
||
"signature": "observability.Checkable"
|
||
},
|
||
{
|
||
"signature": "observability.Identifiable"
|
||
},
|
||
{
|
||
"signature": "Provider"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "New",
|
||
"signature": "func New(logger logging.Logger, cfg Config) Component",
|
||
"doc": "New returns a Component backed by the given configuration.\nThe database is not opened until OnInit is called.",
|
||
"file": "new.go",
|
||
"line": 24
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "Config",
|
||
"signature": "type Config struct",
|
||
"doc": "Config holds SQLite connection settings. Path is required; all other fields\nhave production-safe defaults via [DefaultConfig].",
|
||
"file": "config.go",
|
||
"line": 5,
|
||
"fields": [
|
||
{
|
||
"name": "Path",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_SQLITE_PATH,required\"",
|
||
"doc": "Path is the SQLite file path. Use \":memory:\" for in-memory databases."
|
||
},
|
||
{
|
||
"name": "MaxOpenConns",
|
||
"type": "int",
|
||
"tag": "env:\"EINHERJAR_SQLITE_MAX_OPEN_CONNS\" envDefault:\"1\""
|
||
},
|
||
{
|
||
"name": "MaxIdleConns",
|
||
"type": "int",
|
||
"tag": "env:\"EINHERJAR_SQLITE_MAX_IDLE_CONNS\" envDefault:\"1\""
|
||
},
|
||
{
|
||
"name": "Pragmas",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_SQLITE_PRAGMAS\" envDefault:\"?_journal=WAL\u0026_timeout=5000\u0026_fk=true\"",
|
||
"doc": "Pragmas are appended to the DSN as query parameters.\nDefault enables WAL journal mode, 5-second busy timeout, and FK enforcement."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "DefaultConfig",
|
||
"signature": "func DefaultConfig() Config",
|
||
"doc": "DefaultConfig returns a Config with all optional fields set to production-safe\ndefaults. Callers must supply Path.",
|
||
"file": "config.go",
|
||
"line": 17
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "Config.DSN",
|
||
"signature": "func (c Config) DSN() string",
|
||
"doc": "DSN constructs the SQLite connection string from the configuration.",
|
||
"file": "config.go",
|
||
"line": 26
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Executor",
|
||
"signature": "type Executor interface",
|
||
"doc": "Executor is the shared query interface for both the connection pool and\nan active transaction. Repository code accepts Executor so it works\nidentically inside and outside a [UnitOfWork].",
|
||
"file": "executor.go",
|
||
"line": 11,
|
||
"methods": [
|
||
{
|
||
"name": "ExecContext",
|
||
"signature": "ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)",
|
||
"doc": "ExecContext executes a query that returns no rows."
|
||
},
|
||
{
|
||
"name": "QueryContext",
|
||
"signature": "QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)",
|
||
"doc": "QueryContext executes a query that returns rows."
|
||
},
|
||
{
|
||
"name": "QueryRowContext",
|
||
"signature": "QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row",
|
||
"doc": "QueryRowContext executes a query that returns at most one row."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Provider",
|
||
"signature": "type Provider interface",
|
||
"doc": "Provider is the database access interface consumed by repositories and services.\nAll methods are safe for concurrent use.",
|
||
"file": "provider.go",
|
||
"line": 7,
|
||
"methods": [
|
||
{
|
||
"name": "GetExecutor",
|
||
"signature": "GetExecutor(ctx context.Context) Executor",
|
||
"doc": "GetExecutor returns the active transaction injected by [UnitOfWork] if one\nis present in ctx, otherwise returns the connection pool."
|
||
},
|
||
{
|
||
"name": "Begin",
|
||
"signature": "Begin(ctx context.Context) (Tx, error)",
|
||
"doc": "Begin starts a new transaction."
|
||
},
|
||
{
|
||
"name": "Ping",
|
||
"signature": "Ping(ctx context.Context) error",
|
||
"doc": "Ping verifies that the database connection is alive."
|
||
},
|
||
{
|
||
"name": "HandleError",
|
||
"signature": "HandleError(err error) error",
|
||
"doc": "HandleError maps a database/sql or SQLite error to a typed [xerrors] value.\nCall this at every point where a database error is first observed."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Tx",
|
||
"signature": "type Tx interface",
|
||
"doc": "Tx extends [Executor] with commit and rollback. Obtained via [Provider.Begin]\nwhen manual transaction control is needed.\nPrefer [UnitOfWork] for the common case of a single transactional unit.\n\nNote: Commit and Rollback accept no context argument — this matches the\ndatabase/sql limitation. The underlying *sql.Tx does not support context\ncancellation on Commit or Rollback.",
|
||
"file": "tx.go",
|
||
"line": 10,
|
||
"methods": [
|
||
{
|
||
"signature": "Executor"
|
||
},
|
||
{
|
||
"name": "Commit",
|
||
"signature": "Commit() error",
|
||
"doc": "Commit commits the transaction."
|
||
},
|
||
{
|
||
"name": "Rollback",
|
||
"signature": "Rollback() error",
|
||
"doc": "Rollback rolls back the transaction. Safe to call after Commit."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "UnitOfWork",
|
||
"signature": "type UnitOfWork interface",
|
||
"doc": "UnitOfWork wraps a set of repository operations in a single serialized\ndatabase transaction. The transaction is injected into the context;\n[Provider.GetExecutor] returns it automatically so repository code requires\nno changes to participate.\n\nWrite transactions are serialized through a mutex to prevent SQLITE_BUSY\nerrors. Pass the result of [New] to [NewUnitOfWork] to enable serialization.",
|
||
"file": "unit_of_work.go",
|
||
"line": 12,
|
||
"methods": [
|
||
{
|
||
"name": "Do",
|
||
"signature": "Do(ctx context.Context, fn func(ctx context.Context) error) error",
|
||
"doc": "Do begins a transaction, calls fn with the enriched context, and commits\non success or rolls back on error. The original fn error is always returned\nwhen fn fails, regardless of the rollback outcome."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewUnitOfWork",
|
||
"signature": "func NewUnitOfWork(logger logging.Logger, client Provider) UnitOfWork",
|
||
"doc": "NewUnitOfWork returns a UnitOfWork backed by the given client.\nWhen client is the result of [New], write transactions are serialized\nthrough an internal mutex to prevent SQLITE_BUSY errors.",
|
||
"file": "new.go",
|
||
"line": 31
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "coder",
|
||
"signature": "type coder interface",
|
||
"doc": "coder is the duck-type interface for SQLite extended error codes.\nmodernc.org/sqlite errors implement this interface.",
|
||
"file": "errors.go",
|
||
"line": 12,
|
||
"methods": [
|
||
{
|
||
"name": "Code",
|
||
"signature": "Code() int"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "ctxTxKey",
|
||
"signature": "type ctxTxKey struct",
|
||
"doc": "ctxTxKey is the context key for the active transaction.",
|
||
"file": "new.go",
|
||
"line": 40
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "sqliteImpl",
|
||
"signature": "type sqliteImpl struct",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 44,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
},
|
||
{
|
||
"name": "cfg",
|
||
"type": "Config"
|
||
},
|
||
{
|
||
"name": "db",
|
||
"type": "*sql.DB"
|
||
},
|
||
{
|
||
"name": "mu",
|
||
"type": "sync.RWMutex"
|
||
},
|
||
{
|
||
"name": "writeMu",
|
||
"type": "sync.Mutex",
|
||
"doc": "serializes writes to prevent SQLITE_BUSY"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteImpl.Begin",
|
||
"signature": "func (c *sqliteImpl) Begin(ctx context.Context) (Tx, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 118
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteImpl.ExecContext",
|
||
"signature": "func (c *sqliteImpl) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 132
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteImpl.GetExecutor",
|
||
"signature": "func (c *sqliteImpl) GetExecutor(ctx context.Context) Executor",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 105
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteImpl.HandleError",
|
||
"signature": "func (c *sqliteImpl) HandleError(err error) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 153
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteImpl.HealthCheck",
|
||
"signature": "func (c *sqliteImpl) HealthCheck(ctx context.Context) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 155
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteImpl.ModulePath",
|
||
"signature": "func (c *sqliteImpl) ModulePath() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 7
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteImpl.ModuleVersion",
|
||
"signature": "func (c *sqliteImpl) ModuleVersion() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 9
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteImpl.Name",
|
||
"signature": "func (c *sqliteImpl) Name() string",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 156
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteImpl.OnInit",
|
||
"signature": "func (c *sqliteImpl) OnInit() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 52
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteImpl.OnStart",
|
||
"signature": "func (c *sqliteImpl) OnStart() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 74
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteImpl.OnStop",
|
||
"signature": "func (c *sqliteImpl) OnStop() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 84
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteImpl.Ping",
|
||
"signature": "func (c *sqliteImpl) Ping(ctx context.Context) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 95
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteImpl.Priority",
|
||
"signature": "func (c *sqliteImpl) Priority() observability.Level",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 157
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteImpl.QueryContext",
|
||
"signature": "func (c *sqliteImpl) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 139
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteImpl.QueryRowContext",
|
||
"signature": "func (c *sqliteImpl) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 146
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "sqliteTx",
|
||
"signature": "type sqliteTx struct",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 161,
|
||
"fields": [
|
||
{
|
||
"type": "*sql.Tx",
|
||
"embedded": true
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteTx.Commit",
|
||
"signature": "func (t *sqliteTx) Commit() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 175
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteTx.ExecContext",
|
||
"signature": "func (t *sqliteTx) ExecContext(ctx context.Context, q string, args ...any) (sql.Result, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 163
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteTx.QueryContext",
|
||
"signature": "func (t *sqliteTx) QueryContext(ctx context.Context, q string, args ...any) (*sql.Rows, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 167
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteTx.QueryRowContext",
|
||
"signature": "func (t *sqliteTx) QueryRowContext(ctx context.Context, q string, args ...any) *sql.Row",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 171
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "sqliteTx.Rollback",
|
||
"signature": "func (t *sqliteTx) Rollback() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 176
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "unitOfWork",
|
||
"signature": "type unitOfWork struct",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 180,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
},
|
||
{
|
||
"name": "client",
|
||
"type": "Provider"
|
||
},
|
||
{
|
||
"name": "writeMu",
|
||
"type": "*sync.Mutex"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "unitOfWork.Do",
|
||
"signature": "func (u *unitOfWork) Do(ctx context.Context, fn func(ctx context.Context) error) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 186
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "HandleError",
|
||
"signature": "func HandleError(err error) error",
|
||
"doc": "HandleError maps database/sql and SQLite errors to typed [xerrors] values.\nReturns nil when err is nil. Also available as [Provider.HandleError].\n\nMapped codes:\n - sql.ErrNoRows → ErrNotFound\n - SQLITE_CONSTRAINT_UNIQUE (2067) → ErrAlreadyExists\n - SQLITE_CONSTRAINT_PRIMARYKEY (1555)→ ErrAlreadyExists\n - SQLITE_CONSTRAINT_FOREIGNKEY (787) → ErrInvalidInput\n - all others → ErrInternal",
|
||
"file": "errors.go",
|
||
"line": 29
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "const",
|
||
"name": "sqliteConstraintPrimaryKey",
|
||
"signature": "const (\n sqliteConstraintPrimaryKey = 1555\n sqliteConstraintUnique = 2067\n sqliteConstraintForeignKey = 787\n)",
|
||
"doc": "",
|
||
"file": "errors.go",
|
||
"line": 14
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "const",
|
||
"name": "sqliteConstraintUnique",
|
||
"signature": "const (\n sqliteConstraintPrimaryKey = 1555\n sqliteConstraintUnique = 2067\n sqliteConstraintForeignKey = 787\n)",
|
||
"doc": "",
|
||
"file": "errors.go",
|
||
"line": 14
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "const",
|
||
"name": "sqliteConstraintForeignKey",
|
||
"signature": "const (\n sqliteConstraintPrimaryKey = 1555\n sqliteConstraintUnique = 2067\n sqliteConstraintForeignKey = 787\n)",
|
||
"doc": "",
|
||
"file": "errors.go",
|
||
"line": 14
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "const",
|
||
"name": "modulePath",
|
||
"signature": "const modulePath = \"code.nochebuena.dev/einherjar/db-sqlite\"",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 5
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"kind": "var",
|
||
"name": "_",
|
||
"signature": "var _ observability.Identifiable = (*sqliteImpl)(nil)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 18
|
||
}
|
||
],
|
||
"adrs": null,
|
||
"examples": [
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"title": "Setup",
|
||
"code": "import dbsqlite \"code.nochebuena.dev/einherjar/db-sqlite\"\n\ndb := dbsqlite.New(logger, dbsqlite.DefaultConfig())\nlc.Append(db) // OnInit opens database; OnStop closes it\n// db is observability.Checkable (LevelDegraded):\nsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"title": "Querying",
|
||
"code": "// GetExecutor returns the pool when called outside a UnitOfWork.\nrows, err := db.GetExecutor(ctx).QueryContext(ctx, \"SELECT id, name FROM users WHERE active = ?\", true)\ndefer rows.Close()\n\nvar name string\nerr := db.GetExecutor(ctx).QueryRowContext(ctx, \"SELECT name FROM users WHERE id = ?\", id).Scan(\u0026name)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"title": "Manual transaction",
|
||
"code": "tx, err := db.Begin(ctx)\nif err != nil {\n return err\n}\ndefer tx.Rollback()\n\n_, err = tx.ExecContext(ctx, \"UPDATE accounts SET balance = balance - ? WHERE id = ?\", amount, fromID)\nif err != nil {\n return err\n}\nreturn tx.Commit()",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"title": "Unit of work (recommended)",
|
||
"code": "uow := dbsqlite.NewUnitOfWork(logger, db)\n\nerr := uow.Do(ctx, func(ctx context.Context) error {\n _, err := db.GetExecutor(ctx).ExecContext(ctx, \"INSERT INTO orders (...) VALUES (...)\", ...)\n return err\n})",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"title": "Error handling",
|
||
"code": "if err := db.HandleError(someErr); err != nil {\n // SQLite error codes mapped to xerrors:\n // UNIQUE constraint failed → ErrAlreadyExists\n // FOREIGN KEY constraint → ErrPreconditionFailed\n // NOT NULL constraint → ErrInvalidInput\n // context.Canceled → ErrCancelled\n // context.DeadlineExceeded → ErrDeadlineExceeded\n}",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"title": "Dependency graph",
|
||
"code": "contracts (zero dependencies)\n ↑\n core\n ↑\ndb-sqlite (contracts, core, modernc.org/sqlite)\n ↑\n your app",
|
||
"language": ""
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"subPackage": "",
|
||
"title": "Verification",
|
||
"code": "cd db-sqlite/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .",
|
||
"language": "bash"
|
||
}
|
||
],
|
||
"compliance": {
|
||
"interfaceAsserts": [
|
||
{
|
||
"module": "db-sqlite",
|
||
"interface": "lifecycle.Component",
|
||
"impl": "(Component)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 23
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"interface": "observability.Checkable",
|
||
"impl": "(Component)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 24
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"interface": "Provider",
|
||
"impl": "(Component)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 25
|
||
}
|
||
],
|
||
"tests": [
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestAtMostOneExportedTypePerFile",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 29
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestDefaultConfig_OptionalFields",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 63
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestConfig_DSN",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 78
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestHandleError_Nil",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 91
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestHandleError_NoRows",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 97
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestHandleError_UniqueConstraint",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 101
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestHandleError_PrimaryKey",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 105
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestHandleError_ForeignKey",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 109
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestHandleError_Generic",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 113
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestNew_NotNil",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 119
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestComponent_Name",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 127
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestComponent_Priority",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 134
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestComponent_OnStop_NilDB",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 141
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestComponent_Begin_NilDB",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 148
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestComponent_GetExecutor_ReturnsDB",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 156
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestComponent_GetExecutor_ReturnsTx",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 164
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestUnitOfWork_Commit",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 176
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestUnitOfWork_Rollback",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 187
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestUnitOfWork_InjectsExecutor",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 196
|
||
},
|
||
{
|
||
"module": "db-sqlite",
|
||
"name": "TestUnitOfWork_ReturnsBeginError",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 210
|
||
}
|
||
]
|
||
},
|
||
"readme": "# einherjar/db-sqlite\n\n[](https://code.nochebuena.dev/einherjar/db-sqlite)\n[](LICENSE)\n[](https://go.dev)\n[]()\n\n\u003e Not every hall needs pillars that reach the sky. Sometimes what matters fits in a single room, carried wherever the warrior goes.\n\n`code.nochebuena.dev/einherjar/db-sqlite` is the SQLite database component of the Einherjar framework. It uses the pure-Go `modernc.org/sqlite` driver (no CGO, cross-compilation works without a C toolchain), wraps `database/sql` behind a lifecycle-aware `Component`, and serializes writes through a mutex to prevent `SQLITE_BUSY` under concurrent goroutines. WAL mode is enabled by default.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport dbsqlite \"code.nochebuena.dev/einherjar/db-sqlite\"\n\ndb := dbsqlite.New(logger, dbsqlite.DefaultConfig())\nlc.Append(db) // OnInit opens database; OnStop closes it\n// db is observability.Checkable (LevelDegraded):\nsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)\n```\n\n### Querying\n\n```go\n// GetExecutor returns the pool when called outside a UnitOfWork.\nrows, err := db.GetExecutor(ctx).QueryContext(ctx, \"SELECT id, name FROM users WHERE active = ?\", true)\ndefer rows.Close()\n\nvar name string\nerr := db.GetExecutor(ctx).QueryRowContext(ctx, \"SELECT name FROM users WHERE id = ?\", id).Scan(\u0026name)\n```\n\n### Manual transaction\n\n```go\ntx, err := db.Begin(ctx)\nif err != nil {\n return err\n}\ndefer tx.Rollback()\n\n_, err = tx.ExecContext(ctx, \"UPDATE accounts SET balance = balance - ? WHERE id = ?\", amount, fromID)\nif err != nil {\n return err\n}\nreturn tx.Commit()\n```\n\nNote: `Commit` and `Rollback` do not accept a context — this is a `database/sql` limitation.\n\n### Unit of work (recommended)\n\n`UnitOfWork` wraps the transaction in context so every call to `GetExecutor` inside `uow.Do` automatically returns the active transaction. The internal write mutex prevents concurrent `SQLITE_BUSY` errors; only one goroutine can write at a time.\n\n```go\nuow := dbsqlite.NewUnitOfWork(logger, db)\n\nerr := uow.Do(ctx, func(ctx context.Context) error {\n _, err := db.GetExecutor(ctx).ExecContext(ctx, \"INSERT INTO orders (...) VALUES (...)\", ...)\n return err\n})\n```\n\nIf the function returns an error, the transaction is rolled back. If it returns nil, the transaction is committed.\n\n### Error handling\n\n```go\nif err := db.HandleError(someErr); err != nil {\n // SQLite error codes mapped to xerrors:\n // UNIQUE constraint failed → ErrAlreadyExists\n // FOREIGN KEY constraint → ErrPreconditionFailed\n // NOT NULL constraint → ErrInvalidInput\n // context.Canceled → ErrCancelled\n // context.DeadlineExceeded → ErrDeadlineExceeded\n}\n```\n\n`HandleError` is also available as a package-level function: `dbsqlite.HandleError(err)`.\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_SQLITE_PATH` | Yes | — | Path to the SQLite database file |\n| `EINHERJAR_SQLITE_MAX_OPEN_CONNS` | No | `1` | Maximum open connections (keep at 1 for writes) |\n| `EINHERJAR_SQLITE_MAX_IDLE_CONNS` | No | `1` | Maximum idle connections |\n| `EINHERJAR_SQLITE_PRAGMAS` | No | `?_journal=WAL\u0026_timeout=5000\u0026_fk=true` | DSN pragma string |\n\nThe default pragma string enables WAL mode (better concurrent reads), a 5-second busy timeout, and foreign key enforcement.\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\ndb-sqlite (contracts, core, modernc.org/sqlite)\n ↑\n your app\n```\n\nNo CGO. Cross-compiles to any GOOS/GOARCH without a C toolchain.\n\n---\n\n## Verification\n\n```bash\ncd db-sqlite/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *The hall is built before the warriors arrive.*\n\u003e *That is the only guarantee worth making.*\n",
|
||
"changelog": "# Changelog — einherjar/db-sqlite\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n"
|
||
},
|
||
{
|
||
"name": "httpclient",
|
||
"importPath": "code.nochebuena.dev/einherjar/httpclient",
|
||
"purpose": "To cross the realms, one must know the road — and how to wait when the bridge is down.",
|
||
"doc": "Package httpclient provides a resilient HTTP client with automatic retry,\ncircuit breaking, request-ID propagation, and typed JSON helpers.\n\n# Basic Usage\n\n\tclient := httpclient.NewWithDefaults(logger)\n\tresp, err := client.Do(req)\n\n# Typed JSON Helpers\n\nDoJSON decodes the response body into T without needing a manual http.Request:\n\n\ttype UserResp struct{ ID, Name string }\n\treq, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)\n\tuser, err := httpclient.DoJSON[UserResp](ctx, client, req)\n\nDoJSONRequest marshals a request body, sends it, and decodes the response:\n\n\tresult, err := httpclient.DoJSONRequest[CreateReq, CreateResp](\n\t ctx, client, http.MethodPost, url, createReq)\n\n# Request ID Propagation\n\nIf the context carries a request ID (set by core/logz.WithRequestID or the\nweb/mw.RequestID middleware), it is forwarded as X-Request-ID on every\noutbound attempt, including retries.\n\n# Resilience\n\nThe retry loop (avast/retry-go) wraps individual HTTP attempts. The circuit\nbreaker (sony/gobreaker) wraps the entire retry sequence, so the breaker\nopens after CBThreshold fully-exhausted retry sequences fail — not after\nCBThreshold individual HTTP errors.\n\n# Configuration\n\n\tEINHERJAR_HTTP_CLIENT_NAME — circuit breaker label; default \"http\"\n\tEINHERJAR_HTTP_TIMEOUT — total request timeout; default 30s\n\tEINHERJAR_HTTP_DIAL_TIMEOUT — TCP dial timeout; default 5s\n\tEINHERJAR_HTTP_MAX_RETRIES — attempts per request; default 3\n\tEINHERJAR_HTTP_RETRY_DELAY — base delay between retries; default 1s\n\tEINHERJAR_HTTP_CB_THRESHOLD — consecutive failures to open breaker; default 10\n\tEINHERJAR_HTTP_CB_TIMEOUT — breaker half-open probe interval; default 1m",
|
||
"goVersion": "1.26",
|
||
"dependsOn": [
|
||
"contracts",
|
||
"core"
|
||
],
|
||
"subPackages": [
|
||
{
|
||
"name": "",
|
||
"importPath": "code.nochebuena.dev/einherjar/httpclient",
|
||
"doc": "Package httpclient provides a resilient HTTP client with automatic retry,\ncircuit breaking, request-ID propagation, and typed JSON helpers.\n\n# Basic Usage\n\n\tclient := httpclient.NewWithDefaults(logger)\n\tresp, err := client.Do(req)\n\n# Typed JSON Helpers\n\nDoJSON decodes the response body into T without needing a manual http.Request:\n\n\ttype UserResp struct{ ID, Name string }\n\treq, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)\n\tuser, err := httpclient.DoJSON[UserResp](ctx, client, req)\n\nDoJSONRequest marshals a request body, sends it, and decodes the response:\n\n\tresult, err := httpclient.DoJSONRequest[CreateReq, CreateResp](\n\t ctx, client, http.MethodPost, url, createReq)\n\n# Request ID Propagation\n\nIf the context carries a request ID (set by core/logz.WithRequestID or the\nweb/mw.RequestID middleware), it is forwarded as X-Request-ID on every\noutbound attempt, including retries.\n\n# Resilience\n\nThe retry loop (avast/retry-go) wraps individual HTTP attempts. The circuit\nbreaker (sony/gobreaker) wraps the entire retry sequence, so the breaker\nopens after CBThreshold fully-exhausted retry sequences fail — not after\nCBThreshold individual HTTP errors.\n\n# Configuration\n\n\tEINHERJAR_HTTP_CLIENT_NAME — circuit breaker label; default \"http\"\n\tEINHERJAR_HTTP_TIMEOUT — total request timeout; default 30s\n\tEINHERJAR_HTTP_DIAL_TIMEOUT — TCP dial timeout; default 5s\n\tEINHERJAR_HTTP_MAX_RETRIES — attempts per request; default 3\n\tEINHERJAR_HTTP_RETRY_DELAY — base delay between retries; default 1s\n\tEINHERJAR_HTTP_CB_THRESHOLD — consecutive failures to open breaker; default 10\n\tEINHERJAR_HTTP_CB_TIMEOUT — breaker half-open probe interval; default 1m"
|
||
}
|
||
],
|
||
"symbols": [
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "Config",
|
||
"signature": "type Config struct",
|
||
"doc": "Config holds configuration for the HTTP client.",
|
||
"file": "config.go",
|
||
"line": 6,
|
||
"fields": [
|
||
{
|
||
"name": "Name",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_HTTP_CLIENT_NAME\" envDefault:\"http\"",
|
||
"doc": "Name identifies this client in logs and circuit breaker metrics."
|
||
},
|
||
{
|
||
"name": "Timeout",
|
||
"type": "time.Duration",
|
||
"tag": "env:\"EINHERJAR_HTTP_TIMEOUT\" envDefault:\"30s\""
|
||
},
|
||
{
|
||
"name": "DialTimeout",
|
||
"type": "time.Duration",
|
||
"tag": "env:\"EINHERJAR_HTTP_DIAL_TIMEOUT\" envDefault:\"5s\""
|
||
},
|
||
{
|
||
"name": "MaxRetries",
|
||
"type": "uint",
|
||
"tag": "env:\"EINHERJAR_HTTP_MAX_RETRIES\" envDefault:\"3\""
|
||
},
|
||
{
|
||
"name": "RetryDelay",
|
||
"type": "time.Duration",
|
||
"tag": "env:\"EINHERJAR_HTTP_RETRY_DELAY\" envDefault:\"1s\""
|
||
},
|
||
{
|
||
"name": "CBThreshold",
|
||
"type": "uint32",
|
||
"tag": "env:\"EINHERJAR_HTTP_CB_THRESHOLD\" envDefault:\"10\""
|
||
},
|
||
{
|
||
"name": "CBTimeout",
|
||
"type": "time.Duration",
|
||
"tag": "env:\"EINHERJAR_HTTP_CB_TIMEOUT\" envDefault:\"1m\""
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "DefaultConfig",
|
||
"signature": "func DefaultConfig() Config",
|
||
"doc": "DefaultConfig returns a Config with sensible production defaults.",
|
||
"file": "config.go",
|
||
"line": 18
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Provider",
|
||
"signature": "type Provider interface",
|
||
"doc": "Provider executes HTTP requests with automatic retry and circuit breaking.\nInject Provider into services that make outbound HTTP calls; construct with New or NewWithDefaults.",
|
||
"file": "provider.go",
|
||
"line": 7,
|
||
"methods": [
|
||
{
|
||
"name": "Do",
|
||
"signature": "Do(req *http.Request) (*http.Response, error)"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "New",
|
||
"signature": "func New(logger logging.Logger, cfg Config) Provider",
|
||
"doc": "New returns a Provider with the given configuration.",
|
||
"file": "new.go",
|
||
"line": 23
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewWithDefaults",
|
||
"signature": "func NewWithDefaults(logger logging.Logger) Provider",
|
||
"doc": "NewWithDefaults returns a Provider with sensible defaults.",
|
||
"file": "new.go",
|
||
"line": 53
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "httpClientImpl",
|
||
"signature": "type httpClientImpl struct",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 57,
|
||
"fields": [
|
||
{
|
||
"name": "client",
|
||
"type": "*http.Client"
|
||
},
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
},
|
||
{
|
||
"name": "cfg",
|
||
"type": "Config"
|
||
},
|
||
{
|
||
"name": "cb",
|
||
"type": "*gobreaker.CircuitBreaker"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "httpClientImpl.Do",
|
||
"signature": "func (c *httpClientImpl) Do(req *http.Request) (*http.Response, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 64
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "moduleID",
|
||
"signature": "type moduleID struct",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 14
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "moduleID.ModulePath",
|
||
"signature": "func (m *moduleID) ModulePath() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 18
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "moduleID.ModuleVersion",
|
||
"signature": "func (m *moduleID) ModuleVersion() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 20
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "DoJSON",
|
||
"signature": "func DoJSON[T any](ctx context.Context, client Provider, req *http.Request) (*T, error)",
|
||
"doc": "DoJSON executes req and decodes the JSON response body into T.\nReturns a xerrors-typed error for HTTP 4xx/5xx responses.",
|
||
"file": "helpers.go",
|
||
"line": 15
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "DoJSONRequest",
|
||
"signature": "func DoJSONRequest[Req, Resp any](ctx context.Context, client Provider, method, rawURL string, body Req) (*Resp, error)",
|
||
"doc": "DoJSONRequest marshals body as JSON, sends it with the given method to rawURL,\nand decodes the response into Resp. For requests without a body, use DoJSON instead.",
|
||
"file": "helpers.go",
|
||
"line": 42
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "MapStatusToError",
|
||
"signature": "func MapStatusToError(code int, msg string) error",
|
||
"doc": "MapStatusToError maps an HTTP status code to the matching xerrors type.",
|
||
"file": "helpers.go",
|
||
"line": 56
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"kind": "const",
|
||
"name": "modulePath",
|
||
"signature": "const modulePath = \"code.nochebuena.dev/einherjar/httpclient\"",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 16
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"kind": "var",
|
||
"name": "Module",
|
||
"signature": "var Module observability.Identifiable = \u0026moduleID{}",
|
||
"doc": "Module identifies this package to observability systems.\nhttpclient is a stateless provider — it is not registered with the launcher as a\nlifecycle component. Register Module manually with any version registry if needed.",
|
||
"file": "identifiable.go",
|
||
"line": 12
|
||
}
|
||
],
|
||
"adrs": null,
|
||
"examples": [
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"title": "Setup",
|
||
"code": "import \"code.nochebuena.dev/einherjar/httpclient\"\n\n// With env-var config\nclient := httpclient.New(logger, httpclient.DefaultConfig())\n\n// Or zero-config with defaults\nclient := httpclient.NewWithDefaults(logger)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"title": "Sending requests",
|
||
"code": "req, err := http.NewRequestWithContext(ctx, http.MethodGet, \"https://api.example.com/users\", nil)\nif err != nil {\n return err\n}\n\nresp, err := client.Do(req)\nif err != nil {\n return err\n}\ndefer resp.Body.Close()",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"title": "JSON GET helper",
|
||
"code": "type User struct {\n ID string `json:\"id\"`\n Name string `json:\"name\"`\n}\n\nreq, _ := http.NewRequestWithContext(ctx, http.MethodGet, \"https://api.example.com/users/123\", nil)\nuser, err := httpclient.DoJSON[User](ctx, client, req)\n// user is *User on success",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"title": "JSON POST helper",
|
||
"code": "type CreateReq struct {\n Name string `json:\"name\"`\n Email string `json:\"email\"`\n}\ntype CreateResp struct {\n ID string `json:\"id\"`\n}\n\nresp, err := httpclient.DoJSONRequest[CreateReq, CreateResp](\n ctx, client,\n http.MethodPost, \"https://api.example.com/users\",\n CreateReq{Name: \"Alice\", Email: \"alice@example.com\"},\n)\n// resp is *CreateResp on success",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"title": "Error mapping",
|
||
"code": "// Map HTTP status codes to xerrors for consistent error handling\nerr := httpclient.MapStatusToError(resp.StatusCode, \"upstream error\")\n// 400 → ErrInvalidInput\n// 401 → ErrUnauthorized\n// 403 → ErrPermissionDenied\n// 404 → ErrNotFound\n// 409 → ErrAlreadyExists\n// 429 → ErrRateLimited\n// 503 → ErrUnavailable",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"title": "Dependency graph",
|
||
"code": "contracts (zero dependencies)\n ↑\n core\n ↑\nhttpclient (contracts, core, retry-go, gobreaker)\n ↑\n your app",
|
||
"language": ""
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"subPackage": "",
|
||
"title": "Verification",
|
||
"code": "cd httpclient/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .",
|
||
"language": "bash"
|
||
}
|
||
],
|
||
"compliance": {
|
||
"interfaceAsserts": [],
|
||
"tests": [
|
||
{
|
||
"module": "httpclient",
|
||
"name": "TestAtMostOneExportedTypePerFile",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 25
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"name": "TestDefaultConfig_OptionalFields",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 59
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"name": "TestNew_NotNil",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 86
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"name": "TestNewWithDefaults_NotNil",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 92
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"name": "TestProvider_Do_Success",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 100
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"name": "TestProvider_Do_Retry5xx",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 117
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"name": "TestProvider_Do_InjectsRequestID",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 139
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"name": "TestProvider_Do_NoRequestID",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 157
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"name": "TestProvider_Do_Retry429_WithRetryAfter",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 174
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"name": "TestProvider_Do_Retry429_NoRetryAfter",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 197
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"name": "TestDoJSONRequest_Success",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 221
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"name": "TestDoJSONRequest_4xx",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 243
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"name": "TestDoJSON_Success",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 259
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"name": "TestDoJSON_4xx",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 277
|
||
},
|
||
{
|
||
"module": "httpclient",
|
||
"name": "TestMapStatusToError_AllCodes",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 294
|
||
}
|
||
]
|
||
},
|
||
"readme": "# einherjar/httpclient\n\n[](https://code.nochebuena.dev/einherjar/httpclient)\n[](LICENSE)\n[](https://go.dev)\n\n\u003e To cross the realms, one must know the road — and how to wait when the bridge is down.\n\n`code.nochebuena.dev/einherjar/httpclient` is the outbound HTTP client component of the Einherjar framework. It composes retry (via `avast/retry-go`) and a circuit breaker (via `sony/gobreaker`) behind a single `Provider` interface with one method: `Do`. Generic helpers `DoJSON` and `DoJSONRequest` reduce boilerplate for JSON APIs without hiding the underlying client.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport \"code.nochebuena.dev/einherjar/httpclient\"\n\n// With env-var config\nclient := httpclient.New(logger, httpclient.DefaultConfig())\n\n// Or zero-config with defaults\nclient := httpclient.NewWithDefaults(logger)\n```\n\n`httpclient` is not a `lifecycle.Component` — it is stateless and requires no registration with the launcher.\n\n### Sending requests\n\n```go\nreq, err := http.NewRequestWithContext(ctx, http.MethodGet, \"https://api.example.com/users\", nil)\nif err != nil {\n return err\n}\n\nresp, err := client.Do(req)\nif err != nil {\n return err\n}\ndefer resp.Body.Close()\n```\n\n### JSON GET helper\n\n```go\ntype User struct {\n ID string `json:\"id\"`\n Name string `json:\"name\"`\n}\n\nreq, _ := http.NewRequestWithContext(ctx, http.MethodGet, \"https://api.example.com/users/123\", nil)\nuser, err := httpclient.DoJSON[User](ctx, client, req)\n// user is *User on success\n```\n\n### JSON POST helper\n\n```go\ntype CreateReq struct {\n Name string `json:\"name\"`\n Email string `json:\"email\"`\n}\ntype CreateResp struct {\n ID string `json:\"id\"`\n}\n\nresp, err := httpclient.DoJSONRequest[CreateReq, CreateResp](\n ctx, client,\n http.MethodPost, \"https://api.example.com/users\",\n CreateReq{Name: \"Alice\", Email: \"alice@example.com\"},\n)\n// resp is *CreateResp on success\n```\n\n### Error mapping\n\n```go\n// Map HTTP status codes to xerrors for consistent error handling\nerr := httpclient.MapStatusToError(resp.StatusCode, \"upstream error\")\n// 400 → ErrInvalidInput\n// 401 → ErrUnauthorized\n// 403 → ErrPermissionDenied\n// 404 → ErrNotFound\n// 409 → ErrAlreadyExists\n// 429 → ErrRateLimited\n// 503 → ErrUnavailable\n```\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_HTTP_CLIENT_NAME` | No | `http` | Circuit breaker name (appears in logs) |\n| `EINHERJAR_HTTP_TIMEOUT` | No | `30s` | Total request timeout |\n| `EINHERJAR_HTTP_DIAL_TIMEOUT` | No | `5s` | TCP connection timeout |\n| `EINHERJAR_HTTP_MAX_RETRIES` | No | `3` | Maximum retry attempts |\n| `EINHERJAR_HTTP_RETRY_DELAY` | No | `1s` | Delay between retries |\n| `EINHERJAR_HTTP_CB_THRESHOLD` | No | `10` | Consecutive failures before circuit opens |\n| `EINHERJAR_HTTP_CB_TIMEOUT` | No | `1m` | Time before circuit attempts half-open |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\nhttpclient (contracts, core, retry-go, gobreaker)\n ↑\n your app\n```\n\n---\n\n## Verification\n\n```bash\ncd httpclient/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *A warrior who cannot reach the other realm is useless to the battle.*\n\u003e *Build the bridge. Make it resilient. Know when to wait.*\n",
|
||
"changelog": "# Changelog — einherjar/httpclient\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1. No code or API changes.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n"
|
||
},
|
||
{
|
||
"name": "smtp",
|
||
"importPath": "code.nochebuena.dev/einherjar/smtp",
|
||
"purpose": "A raven sent from Valhalla reaches its destination. The sender does not wait at the window.",
|
||
"doc": "Package smtp provides a lifecycle-managed SMTP client for sending email\nin Einherjar applications.\n\n# Overview\n\n[New] returns a [Component] that satisfies [lifecycle.Component] lifecycle\nhooks, [observability.Checkable] with degraded priority, and the [Sender]\ninterface for dispatching email. When [Config.Host] is empty, [New] returns\na no-op implementation that logs a warning and silently discards every message —\nemail failure must never block a transaction.\n\n# Lifecycle Registration\n\n\tclient := smtp.New(logger, cfg)\n\tlc.Append(client) // lifecycle: OnInit → OnStop\n\nclient satisfies [observability.Checkable], so it can back a health endpoint via\nweb/health.NewHandler:\n\n\tsrv.Get(\"/health\", health.NewHandler(logger, client).ServeHTTP)\n\n# Sending\n\nServices depend only on [Sender]. Build a [Message] with a pre-rendered body,\nthen call Send:\n\n\tmsg := smtp.Message{\n\t To: []string{\"alice@example.com\"},\n\t Subject: \"Your receipt\",\n\t Body: rendered, // pre-rendered string from Template.Render\n\t ContentType: \"text/html\",\n\t}\n\tif err := mailer.Send(ctx, msg); err != nil {\n\t return err\n\t}\n\n# Template Rendering\n\n[ParseFS] wraps stdlib html/template for HTML email rendering. Rendering is\nintentionally separate from Send — callers render to a string and assign it\nto Message.Body. This keeps Send unit-testable without template involvement.\n\n\ttmpl, err := smtp.ParseFS(os.DirFS(\"templates\"), \"*.html\")\n\tbody, err := tmpl.Render(\"receipt.html\", data)\n\tmsg := smtp.Message{Body: body, ContentType: \"text/html\", ...}\n\n# Configuration\n\nAll fields are read from environment variables with the EINHERJAR_SMTP_* prefix:\n\n - EINHERJAR_SMTP_HOST — SMTP server hostname (empty = no-op mode)\n - EINHERJAR_SMTP_PORT — default: 587 (STARTTLS)\n - EINHERJAR_SMTP_USER — auth username (optional)\n - EINHERJAR_SMTP_PASSWORD — auth password (optional)\n - EINHERJAR_SMTP_FROM — envelope sender address",
|
||
"goVersion": "1.26",
|
||
"dependsOn": [
|
||
"contracts",
|
||
"core"
|
||
],
|
||
"subPackages": [
|
||
{
|
||
"name": "",
|
||
"importPath": "code.nochebuena.dev/einherjar/smtp",
|
||
"doc": "Package smtp provides a lifecycle-managed SMTP client for sending email\nin Einherjar applications.\n\n# Overview\n\n[New] returns a [Component] that satisfies [lifecycle.Component] lifecycle\nhooks, [observability.Checkable] with degraded priority, and the [Sender]\ninterface for dispatching email. When [Config.Host] is empty, [New] returns\na no-op implementation that logs a warning and silently discards every message —\nemail failure must never block a transaction.\n\n# Lifecycle Registration\n\n\tclient := smtp.New(logger, cfg)\n\tlc.Append(client) // lifecycle: OnInit → OnStop\n\nclient satisfies [observability.Checkable], so it can back a health endpoint via\nweb/health.NewHandler:\n\n\tsrv.Get(\"/health\", health.NewHandler(logger, client).ServeHTTP)\n\n# Sending\n\nServices depend only on [Sender]. Build a [Message] with a pre-rendered body,\nthen call Send:\n\n\tmsg := smtp.Message{\n\t To: []string{\"alice@example.com\"},\n\t Subject: \"Your receipt\",\n\t Body: rendered, // pre-rendered string from Template.Render\n\t ContentType: \"text/html\",\n\t}\n\tif err := mailer.Send(ctx, msg); err != nil {\n\t return err\n\t}\n\n# Template Rendering\n\n[ParseFS] wraps stdlib html/template for HTML email rendering. Rendering is\nintentionally separate from Send — callers render to a string and assign it\nto Message.Body. This keeps Send unit-testable without template involvement.\n\n\ttmpl, err := smtp.ParseFS(os.DirFS(\"templates\"), \"*.html\")\n\tbody, err := tmpl.Render(\"receipt.html\", data)\n\tmsg := smtp.Message{Body: body, ContentType: \"text/html\", ...}\n\n# Configuration\n\nAll fields are read from environment variables with the EINHERJAR_SMTP_* prefix:\n\n - EINHERJAR_SMTP_HOST — SMTP server hostname (empty = no-op mode)\n - EINHERJAR_SMTP_PORT — default: 587 (STARTTLS)\n - EINHERJAR_SMTP_USER — auth username (optional)\n - EINHERJAR_SMTP_PASSWORD — auth password (optional)\n - EINHERJAR_SMTP_FROM — envelope sender address"
|
||
}
|
||
],
|
||
"symbols": [
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "Attachment",
|
||
"signature": "type Attachment struct",
|
||
"doc": "Attachment is a named file included in a [Message].\nData is consumed once during [Sender.Send] and must not be reused.",
|
||
"file": "attachment.go",
|
||
"line": 7,
|
||
"fields": [
|
||
{
|
||
"name": "Name",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"name": "ContentType",
|
||
"type": "string",
|
||
"doc": "e.g. \"application/pdf\"; defaults to \"application/octet-stream\""
|
||
},
|
||
{
|
||
"name": "Data",
|
||
"type": "io.Reader"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Component",
|
||
"signature": "type Component interface",
|
||
"doc": "Component is the full smtp capability: lifecycle management, health reporting,\nand the [Sender] interface for dispatching email.\nRegister with launcher and health before starting.",
|
||
"file": "component.go",
|
||
"line": 11,
|
||
"methods": [
|
||
{
|
||
"signature": "lifecycle.Component"
|
||
},
|
||
{
|
||
"signature": "observability.Checkable"
|
||
},
|
||
{
|
||
"signature": "observability.Identifiable"
|
||
},
|
||
{
|
||
"signature": "Sender"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "New",
|
||
"signature": "func New(logger logging.Logger, cfg Config) Component",
|
||
"doc": "New returns a Component backed by the given configuration.\nWhen cfg.Host is empty, New returns a no-op client that logs a warning\nand silently discards every message — SMTP absence must never block a transaction.",
|
||
"file": "new.go",
|
||
"line": 30
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "Config",
|
||
"signature": "type Config struct",
|
||
"doc": "Config holds SMTP connection settings. All fields are optional; an empty Host\nactivates no-op mode. Use [DefaultConfig] to start from production-safe defaults.",
|
||
"file": "config.go",
|
||
"line": 5,
|
||
"fields": [
|
||
{
|
||
"name": "Host",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_SMTP_HOST\"",
|
||
"doc": "Host is the SMTP server hostname. Empty string enables no-op mode."
|
||
},
|
||
{
|
||
"name": "Port",
|
||
"type": "int",
|
||
"tag": "env:\"EINHERJAR_SMTP_PORT\" envDefault:\"587\""
|
||
},
|
||
{
|
||
"name": "User",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_SMTP_USER\""
|
||
},
|
||
{
|
||
"name": "Password",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_SMTP_PASSWORD\""
|
||
},
|
||
{
|
||
"name": "From",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_SMTP_FROM\"",
|
||
"doc": "From is the envelope sender address used for all outgoing messages."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "DefaultConfig",
|
||
"signature": "func DefaultConfig() Config",
|
||
"doc": "DefaultConfig returns a Config with production-safe defaults.\nCallers must supply Host and From for real SMTP delivery.",
|
||
"file": "config.go",
|
||
"line": 17
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "Message",
|
||
"signature": "type Message struct",
|
||
"doc": "Message is a fully-specified email ready for delivery.\nBody must be pre-rendered before passing to [Sender.Send] —\nuse [Template.Render] to produce it from an HTML template.",
|
||
"file": "message.go",
|
||
"line": 6,
|
||
"fields": [
|
||
{
|
||
"name": "To",
|
||
"type": "[]string"
|
||
},
|
||
{
|
||
"name": "CC",
|
||
"type": "[]string"
|
||
},
|
||
{
|
||
"name": "BCC",
|
||
"type": "[]string"
|
||
},
|
||
{
|
||
"name": "ReplyTo",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"name": "Subject",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"name": "Body",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"name": "ContentType",
|
||
"type": "string",
|
||
"doc": "\"text/plain\" or \"text/html\"; defaults to \"text/plain\""
|
||
},
|
||
{
|
||
"name": "Attachments",
|
||
"type": "[]Attachment"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Sender",
|
||
"signature": "type Sender interface",
|
||
"doc": "Sender is the interface consumed by application services. Implementations\ninclude the real SMTP client and a no-op client used when Host is empty.\nSatisfied by [Component] — pass the result of [New] wherever a Sender is expected.",
|
||
"file": "sender.go",
|
||
"line": 8,
|
||
"methods": [
|
||
{
|
||
"name": "Send",
|
||
"signature": "Send(ctx context.Context, msg Message) error",
|
||
"doc": "Send delivers msg via SMTP. Returns nil on success or a typed [xerrors] error\non failure. In no-op mode, Send always returns nil."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "Template",
|
||
"signature": "type Template struct",
|
||
"doc": "Template wraps stdlib html/template for HTML email rendering.\nRendering is intentionally separate from [Sender.Send] — callers render\na template to a string and assign it to [Message.Body].",
|
||
"file": "template.go",
|
||
"line": 14,
|
||
"fields": [
|
||
{
|
||
"name": "t",
|
||
"type": "*template.Template"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "ParseFS",
|
||
"signature": "func ParseFS(fsys fs.FS, patterns ...string) (*Template, error)",
|
||
"doc": "ParseFS parses the named pattern files from fsys into a Template.\nReturns an error if any pattern matches no files or a file fails to parse.",
|
||
"file": "template.go",
|
||
"line": 18
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "Template.Render",
|
||
"signature": "func (t *Template) Render(name string, data any) (string, error)",
|
||
"doc": "Render executes the named template with data and returns the rendered string.",
|
||
"file": "template.go",
|
||
"line": 27
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "noopClient",
|
||
"signature": "type noopClient struct",
|
||
"doc": "",
|
||
"file": "noop.go",
|
||
"line": 10,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "noopClient.HealthCheck",
|
||
"signature": "func (n *noopClient) HealthCheck(_ context.Context) error",
|
||
"doc": "",
|
||
"file": "noop.go",
|
||
"line": 21
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "noopClient.ModulePath",
|
||
"signature": "func (c *noopClient) ModulePath() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 11
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "noopClient.ModuleVersion",
|
||
"signature": "func (c *noopClient) ModuleVersion() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 13
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "noopClient.Name",
|
||
"signature": "func (n *noopClient) Name() string",
|
||
"doc": "",
|
||
"file": "noop.go",
|
||
"line": 18
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "noopClient.OnInit",
|
||
"signature": "func (n *noopClient) OnInit() error",
|
||
"doc": "",
|
||
"file": "noop.go",
|
||
"line": 14
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "noopClient.OnStart",
|
||
"signature": "func (n *noopClient) OnStart() error",
|
||
"doc": "",
|
||
"file": "noop.go",
|
||
"line": 15
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "noopClient.OnStop",
|
||
"signature": "func (n *noopClient) OnStop() error",
|
||
"doc": "",
|
||
"file": "noop.go",
|
||
"line": 16
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "noopClient.Priority",
|
||
"signature": "func (n *noopClient) Priority() observability.Level",
|
||
"doc": "",
|
||
"file": "noop.go",
|
||
"line": 19
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "noopClient.Send",
|
||
"signature": "func (n *noopClient) Send(_ context.Context, msg Message) error",
|
||
"doc": "",
|
||
"file": "noop.go",
|
||
"line": 23
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "smtpClient",
|
||
"signature": "type smtpClient struct",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 40,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
},
|
||
{
|
||
"name": "cfg",
|
||
"type": "Config"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "smtpClient.HealthCheck",
|
||
"signature": "func (c *smtpClient) HealthCheck(ctx context.Context) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 52
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "smtpClient.ModulePath",
|
||
"signature": "func (c *smtpClient) ModulePath() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 7
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "smtpClient.ModuleVersion",
|
||
"signature": "func (c *smtpClient) ModuleVersion() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 9
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "smtpClient.Name",
|
||
"signature": "func (c *smtpClient) Name() string",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 49
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "smtpClient.OnInit",
|
||
"signature": "func (c *smtpClient) OnInit() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 45
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "smtpClient.OnStart",
|
||
"signature": "func (c *smtpClient) OnStart() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 46
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "smtpClient.OnStop",
|
||
"signature": "func (c *smtpClient) OnStop() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 47
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "smtpClient.Priority",
|
||
"signature": "func (c *smtpClient) Priority() observability.Level",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 50
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "smtpClient.Send",
|
||
"signature": "func (c *smtpClient) Send(_ context.Context, msg Message) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 62
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "buildRawMessage",
|
||
"signature": "func buildRawMessage(from string, msg Message) ([]byte, error)",
|
||
"doc": "buildRawMessage constructs a RFC 5322 MIME message ready for smtp.SendMail.\nBCC recipients are included in the SMTP envelope (via the caller) but are\nintentionally omitted from the message headers.",
|
||
"file": "new.go",
|
||
"line": 88
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "smtpModuleVersion",
|
||
"signature": "func smtpModuleVersion() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 15
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "writeHeader",
|
||
"signature": "func writeHeader(buf *bytes.Buffer, key, value string)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 173
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "const",
|
||
"name": "modulePath",
|
||
"signature": "const modulePath = \"code.nochebuena.dev/einherjar/smtp\"",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 5
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "var",
|
||
"name": "_",
|
||
"signature": "var _ observability.Identifiable = (*smtpClient)(nil)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 24
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"kind": "var",
|
||
"name": "_",
|
||
"signature": "var _ observability.Identifiable = (*noopClient)(nil)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 25
|
||
}
|
||
],
|
||
"adrs": null,
|
||
"examples": [
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"title": "Setup",
|
||
"code": "import \"code.nochebuena.dev/einherjar/smtp\"\n\nmailer := smtp.New(logger, smtp.DefaultConfig())\nlc.Append(mailer) // OnInit verifies credentials; OnStop is a no-op\n// mailer is observability.Checkable (dial check, LevelDegraded):\nsrv.Get(\"/health\", health.NewHandler(logger, mailer).ServeHTTP)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"title": "Sending a plain-text email",
|
||
"code": "err := mailer.Send(ctx, smtp.Message{\n To: []string{\"user@example.com\"},\n Subject: \"Welcome to the service\",\n Body: \"Your account is ready.\",\n})",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"title": "Sending HTML with attachments",
|
||
"code": "err := mailer.Send(ctx, smtp.Message{\n To: []string{\"user@example.com\"},\n CC: []string{\"support@example.com\"},\n BCC: []string{\"audit@example.com\"}, // envelope only — never in headers\n Subject: \"Your invoice\",\n Body: renderedHTML,\n ContentType: \"text/html\",\n Attachments: []smtp.Attachment{\n {\n Name: \"invoice.pdf\",\n ContentType: \"application/pdf\",\n Data: pdfReader, // consumed once; do not reuse\n },\n },\n})",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"title": "Template rendering",
|
||
"code": "tmpl, err := smtp.ParseFS(os.DirFS(\"templates\"), \"*.html\")\nif err != nil {\n return err\n}\n\nbody, err := tmpl.Render(\"welcome.html\", map[string]any{\n \"Name\": user.Name,\n \"URL\": activationURL,\n})\nif err != nil {\n return err\n}\n\nerr = mailer.Send(ctx, smtp.Message{\n To: []string{user.Email},\n Subject: \"Activate your account\",\n Body: body,\n ContentType: \"text/html\",\n})",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"title": "Dependency graph",
|
||
"code": "contracts (zero dependencies)\n ↑\n core\n ↑\nsmtp (contracts, core, stdlib only)\n ↑\n your app",
|
||
"language": ""
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"subPackage": "",
|
||
"title": "Verification",
|
||
"code": "cd smtp/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .",
|
||
"language": "bash"
|
||
}
|
||
],
|
||
"compliance": {
|
||
"interfaceAsserts": [
|
||
{
|
||
"module": "smtp",
|
||
"interface": "lifecycle.Component",
|
||
"impl": "(Component)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 23
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"interface": "observability.Checkable",
|
||
"impl": "(Component)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 24
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"interface": "Sender",
|
||
"impl": "(Component)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 25
|
||
}
|
||
],
|
||
"tests": [
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestAtMostOneExportedTypePerFile",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 29
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestDefaultConfig_OptionalFields",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 63
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestNew_ReturnsNoopWhenHostEmpty",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 72
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestNew_ReturnsClientWhenHostSet",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 83
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestNoopClient_Send_ReturnsNil",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 95
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestNoopClient_HealthCheck_ReturnsNil",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 103
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestNoopClient_Priority_IsDegraded",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 110
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestNoopClient_Lifecycle_ReturnsNil",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 117
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestSmtpClient_Priority_IsDegraded",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 132
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestSmtpClient_Name",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 139
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestBuildRawMessage_NoAttachments_PlainText",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 148
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestBuildRawMessage_NoAttachments_HTML",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 167
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestBuildRawMessage_WithCC_ReplyTo",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 183
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestBuildRawMessage_WithAttachment",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 204
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestBuildRawMessage_BCC_NotInHeaders",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 234
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestTemplate_Render",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 252
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestTemplate_Render_UnknownTemplate",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 271
|
||
},
|
||
{
|
||
"module": "smtp",
|
||
"name": "TestTemplate_ParseFS_Error",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 286
|
||
}
|
||
]
|
||
},
|
||
"readme": "# einherjar/smtp\n\n[](https://code.nochebuena.dev/einherjar/smtp)\n[](LICENSE)\n[](https://go.dev)\n[]()\n\n\u003e A raven sent from Valhalla reaches its destination. The sender does not wait at the window.\n\n`code.nochebuena.dev/einherjar/smtp` is the email sender component of the Einherjar framework. It is built entirely on the Go standard library (`net/smtp`, `mime/multipart`, `html/template`) with no external dependencies. When `EINHERJAR_SMTP_HOST` is empty, `New` returns a silent no-op — email absence never blocks a transaction, user registration, or order placement. Health priority is `LevelDegraded`.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport \"code.nochebuena.dev/einherjar/smtp\"\n\nmailer := smtp.New(logger, smtp.DefaultConfig())\nlc.Append(mailer) // OnInit verifies credentials; OnStop is a no-op\n// mailer is observability.Checkable (dial check, LevelDegraded):\nsrv.Get(\"/health\", health.NewHandler(logger, mailer).ServeHTTP)\n```\n\nWhen `cfg.Host` is empty, `New` returns a no-op that logs every `Send` call at debug level and always returns nil. No code changes are needed between environments.\n\n### Sending a plain-text email\n\n```go\nerr := mailer.Send(ctx, smtp.Message{\n To: []string{\"user@example.com\"},\n Subject: \"Welcome to the service\",\n Body: \"Your account is ready.\",\n})\n```\n\n### Sending HTML with attachments\n\n```go\nerr := mailer.Send(ctx, smtp.Message{\n To: []string{\"user@example.com\"},\n CC: []string{\"support@example.com\"},\n BCC: []string{\"audit@example.com\"}, // envelope only — never in headers\n Subject: \"Your invoice\",\n Body: renderedHTML,\n ContentType: \"text/html\",\n Attachments: []smtp.Attachment{\n {\n Name: \"invoice.pdf\",\n ContentType: \"application/pdf\",\n Data: pdfReader, // consumed once; do not reuse\n },\n },\n})\n```\n\n### Template rendering\n\nTemplate rendering is intentionally separate from `Send`. Render to a string first, then assign to `Message.Body`. This keeps the transport and rendering concerns independent.\n\n```go\ntmpl, err := smtp.ParseFS(os.DirFS(\"templates\"), \"*.html\")\nif err != nil {\n return err\n}\n\nbody, err := tmpl.Render(\"welcome.html\", map[string]any{\n \"Name\": user.Name,\n \"URL\": activationURL,\n})\nif err != nil {\n return err\n}\n\nerr = mailer.Send(ctx, smtp.Message{\n To: []string{user.Email},\n Subject: \"Activate your account\",\n Body: body,\n ContentType: \"text/html\",\n})\n```\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_SMTP_HOST` | No | `\"\"` | SMTP host. Empty → no-op sender |\n| `EINHERJAR_SMTP_PORT` | No | `587` | SMTP port |\n| `EINHERJAR_SMTP_USER` | No | `\"\"` | Auth username |\n| `EINHERJAR_SMTP_PASSWORD` | No | `\"\"` | Auth password |\n| `EINHERJAR_SMTP_FROM` | No | `\"\"` | Default From address |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\nsmtp (contracts, core, stdlib only)\n ↑\n your app\n```\n\nNo external dependencies beyond the Go standard library.\n\n---\n\n## Verification\n\n```bash\ncd smtp/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *The message matters. The raven is only the path.*\n\u003e *Make the path reliable. Do not let it stop the battle.*\n",
|
||
"changelog": "# Changelog — einherjar/smtp\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n"
|
||
},
|
||
{
|
||
"name": "spa-server",
|
||
"importPath": "code.nochebuena.dev/einherjar/spa-server",
|
||
"purpose": "A shield wall holds because every warrior knows their position. The SPA asks only for the wall — not for every soldier's name.",
|
||
"doc": "Package spaserver provides a lifecycle-managed HTTP server for serving\nsingle-page applications (SPA) and progressive web apps (PWA) from a\ncontainer.\n\n# Overview\n\nThe server delivers static assets from a configurable directory and falls\nback to index.html for any request path that does not map to an existing\nfile — the standard SPA routing contract. A health endpoint is included\nat /health with the same JSON wire format used across all Einherjar modules.\n\n# Lifecycle\n\n[NewServer] returns a [lifecycle.Component] that integrates directly with\n[launcher.New]. [Server.OnInit] builds the HTTP mux. [Server.OnStart]\nbegins serving in a goroutine. [Server.OnStop] performs a graceful shutdown.\n\n\tcfg := spaserver.DefaultConfig()\n\tsrv := spaserver.NewServer(logger, cfg)\n\n\tlc := launcher.New(logger)\n\tlc.Append(srv)\n\tif err := lc.Run(); err != nil {\n\t log.Fatal(err)\n\t}\n\n# Configuration\n\nAll fields are read from environment variables:\n\n - EINHERJAR_SPA_PORT — listen port (default: 8080)\n - EINHERJAR_SPA_STATIC_DIR — path to the directory containing index.html and assets (default: /srv/www)\n - EINHERJAR_LOG_LEVEL — log level: DEBUG, INFO, WARN, ERROR (default: INFO)\n\n# Container usage\n\nThe module ships as a ready-to-use base image. A downstream SPA Dockerfile\nonly needs to copy the built assets:\n\n\tFROM code.nochebuena.dev/einherjar/spa-server:v1.0.0\n\tCOPY dist/ /srv/www/",
|
||
"goVersion": "1.26",
|
||
"dependsOn": [
|
||
"contracts",
|
||
"core"
|
||
],
|
||
"subPackages": [
|
||
{
|
||
"name": "",
|
||
"importPath": "code.nochebuena.dev/einherjar/spa-server",
|
||
"doc": "Package spaserver provides a lifecycle-managed HTTP server for serving\nsingle-page applications (SPA) and progressive web apps (PWA) from a\ncontainer.\n\n# Overview\n\nThe server delivers static assets from a configurable directory and falls\nback to index.html for any request path that does not map to an existing\nfile — the standard SPA routing contract. A health endpoint is included\nat /health with the same JSON wire format used across all Einherjar modules.\n\n# Lifecycle\n\n[NewServer] returns a [lifecycle.Component] that integrates directly with\n[launcher.New]. [Server.OnInit] builds the HTTP mux. [Server.OnStart]\nbegins serving in a goroutine. [Server.OnStop] performs a graceful shutdown.\n\n\tcfg := spaserver.DefaultConfig()\n\tsrv := spaserver.NewServer(logger, cfg)\n\n\tlc := launcher.New(logger)\n\tlc.Append(srv)\n\tif err := lc.Run(); err != nil {\n\t log.Fatal(err)\n\t}\n\n# Configuration\n\nAll fields are read from environment variables:\n\n - EINHERJAR_SPA_PORT — listen port (default: 8080)\n - EINHERJAR_SPA_STATIC_DIR — path to the directory containing index.html and assets (default: /srv/www)\n - EINHERJAR_LOG_LEVEL — log level: DEBUG, INFO, WARN, ERROR (default: INFO)\n\n# Container usage\n\nThe module ships as a ready-to-use base image. A downstream SPA Dockerfile\nonly needs to copy the built assets:\n\n\tFROM code.nochebuena.dev/einherjar/spa-server:v1.0.0\n\tCOPY dist/ /srv/www/"
|
||
},
|
||
{
|
||
"name": "health",
|
||
"importPath": "code.nochebuena.dev/einherjar/spa-server/health",
|
||
"doc": "Package health provides the /health HTTP handler for spa-server.\nThe wire format is identical to web/health.Response so responses are\nconsistent across all Einherjar modules — without importing the web module."
|
||
},
|
||
{
|
||
"name": "spa",
|
||
"importPath": "code.nochebuena.dev/einherjar/spa-server/spa",
|
||
"doc": "Package spa provides the HTTP handler that serves a single-page application."
|
||
}
|
||
],
|
||
"symbols": [
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "Config",
|
||
"signature": "type Config struct",
|
||
"doc": "Config holds the server configuration.\nAll fields are populated from environment variables by [DefaultConfig].",
|
||
"file": "config.go",
|
||
"line": 10,
|
||
"fields": [
|
||
{
|
||
"name": "Port",
|
||
"type": "int",
|
||
"tag": "env:\"EINHERJAR_SPA_PORT\" envDefault:\"8080\"",
|
||
"doc": "Port is the TCP port the server listens on."
|
||
},
|
||
{
|
||
"name": "StaticDir",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_SPA_STATIC_DIR\" envDefault:\"/srv/www\"",
|
||
"doc": "StaticDir is the filesystem path that contains index.html and all assets."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "DefaultConfig",
|
||
"signature": "func DefaultConfig() Config",
|
||
"doc": "DefaultConfig returns a Config populated from environment variables,\nfalling back to safe defaults when variables are unset.",
|
||
"file": "config.go",
|
||
"line": 19
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "Server",
|
||
"signature": "type Server struct",
|
||
"doc": "Server is a lifecycle-managed HTTP server that serves a single-page application.\nRegister with [launcher.New] via Append — it implements [lifecycle.Component].",
|
||
"file": "server.go",
|
||
"line": 19,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
},
|
||
{
|
||
"name": "cfg",
|
||
"type": "Config"
|
||
},
|
||
{
|
||
"name": "srv",
|
||
"type": "*http.Server"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewServer",
|
||
"signature": "func NewServer(logger logging.Logger, cfg Config) *Server",
|
||
"doc": "NewServer returns a Server configured by cfg.",
|
||
"file": "server.go",
|
||
"line": 26
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "Server.OnInit",
|
||
"signature": "func (s *Server) OnInit() error",
|
||
"doc": "OnInit builds the HTTP mux and configures the underlying http.Server.",
|
||
"file": "server.go",
|
||
"line": 31
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "Server.OnStart",
|
||
"signature": "func (s *Server) OnStart() error",
|
||
"doc": "OnStart begins serving HTTP requests in a background goroutine.\nThe TCP listener binds synchronously so a port conflict surfaces immediately.",
|
||
"file": "server.go",
|
||
"line": 45
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "Server.OnStop",
|
||
"signature": "func (s *Server) OnStop() error",
|
||
"doc": "OnStop performs a graceful shutdown, waiting up to 10 seconds for in-flight\nrequests to complete before forcefully closing connections.",
|
||
"file": "server.go",
|
||
"line": 69
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "newListener",
|
||
"signature": "func newListener(addr string) (net.Listener, error)",
|
||
"doc": "",
|
||
"file": "server.go",
|
||
"line": 59
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "",
|
||
"kind": "const",
|
||
"name": "shutdownTimeout",
|
||
"signature": "const shutdownTimeout = 10 * time.Second",
|
||
"doc": "",
|
||
"file": "server.go",
|
||
"line": 15
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "health",
|
||
"kind": "type",
|
||
"name": "handler",
|
||
"signature": "type handler struct",
|
||
"doc": "",
|
||
"file": "health/handler.go",
|
||
"line": 19,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "health",
|
||
"kind": "method",
|
||
"name": "handler.ServeHTTP",
|
||
"signature": "func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request)",
|
||
"doc": "",
|
||
"file": "health/handler.go",
|
||
"line": 29
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "health",
|
||
"kind": "type",
|
||
"name": "response",
|
||
"signature": "type response struct",
|
||
"doc": "response mirrors web/health.Response: {\"status\":\"UP\",\"components\":{}}.",
|
||
"file": "health/handler.go",
|
||
"line": 14,
|
||
"fields": [
|
||
{
|
||
"name": "Status",
|
||
"type": "string",
|
||
"tag": "json:\"status\""
|
||
},
|
||
{
|
||
"name": "Components",
|
||
"type": "map[string]string",
|
||
"tag": "json:\"components\""
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "health",
|
||
"kind": "func",
|
||
"name": "NewHandler",
|
||
"signature": "func NewHandler(logger logging.Logger) http.Handler",
|
||
"doc": "NewHandler returns an http.Handler that always responds 200 UP.\nspa-server has no external dependencies to probe; being reachable is the health check.",
|
||
"file": "health/handler.go",
|
||
"line": 25
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "spa",
|
||
"kind": "type",
|
||
"name": "handler",
|
||
"signature": "type handler struct",
|
||
"doc": "",
|
||
"file": "spa/handler.go",
|
||
"line": 11,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
},
|
||
{
|
||
"name": "staticDir",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"name": "fs",
|
||
"type": "http.Handler"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "spa",
|
||
"kind": "method",
|
||
"name": "handler.ServeHTTP",
|
||
"signature": "func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request)",
|
||
"doc": "",
|
||
"file": "spa/handler.go",
|
||
"line": 28
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "spa",
|
||
"kind": "func",
|
||
"name": "NewHandler",
|
||
"signature": "func NewHandler(logger logging.Logger, staticDir string) http.Handler",
|
||
"doc": "NewHandler returns an http.Handler that serves static files from staticDir.\nRequests for paths that exist on disk are served directly.\nAny other path receives index.html, delegating routing to the SPA.",
|
||
"file": "spa/handler.go",
|
||
"line": 20
|
||
}
|
||
],
|
||
"adrs": null,
|
||
"examples": [
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "",
|
||
"title": "Container usage",
|
||
"code": "FROM code.nochebuena.dev/einherjar/spa-server:v1.0.0\nCOPY dist/ /srv/www/",
|
||
"language": "dockerfile"
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "",
|
||
"title": "Health endpoint",
|
||
"code": "{\"status\":\"UP\",\"components\":{}}",
|
||
"language": "json"
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "",
|
||
"title": "Dependency graph",
|
||
"code": "contracts (zero dependencies)\n ↑\n core\n ↑\nspa-server (contracts, core, stdlib only)",
|
||
"language": ""
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"subPackage": "",
|
||
"title": "Verification",
|
||
"code": "cd spa-server/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .",
|
||
"language": "bash"
|
||
}
|
||
],
|
||
"compliance": {
|
||
"interfaceAsserts": [
|
||
{
|
||
"module": "spa-server",
|
||
"interface": "lifecycle.Component",
|
||
"impl": "(*Server)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 18
|
||
}
|
||
],
|
||
"tests": [
|
||
{
|
||
"module": "spa-server",
|
||
"name": "TestAtMostOneExportedTypePerFile",
|
||
"doc": "TestAtMostOneExportedTypePerFile enforces CT-6: at most one exported TypeSpec\nper non-test, non-doc .go file in the root package.",
|
||
"file": "compliance_test.go",
|
||
"line": 22
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"name": "TestDefaultConfig_Defaults",
|
||
"doc": "TestDefaultConfig_Defaults verifies that DefaultConfig returns non-zero values (S-4).",
|
||
"file": "compliance_test.go",
|
||
"line": 55
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"name": "TestDefaultConfig_EnvOverride",
|
||
"doc": "TestDefaultConfig_EnvOverride verifies that environment variables are respected.",
|
||
"file": "compliance_test.go",
|
||
"line": 66
|
||
},
|
||
{
|
||
"module": "spa-server",
|
||
"name": "TestServer_Lifecycle",
|
||
"doc": "TestServer_Lifecycle verifies that OnInit/OnStart/OnStop complete without error\non a free port.",
|
||
"file": "compliance_test.go",
|
||
"line": 81
|
||
}
|
||
]
|
||
},
|
||
"readme": "# einherjar/spa-server\n\n[](https://code.nochebuena.dev/einherjar/spa-server)\n[](LICENSE)\n[](https://go.dev)\n\n\u003e A shield wall holds because every warrior knows their position. The SPA asks only for the wall — not for every soldier's name.\n\n`code.nochebuena.dev/einherjar/spa-server` is a container-first HTTP server for single-page applications and progressive web apps. It serves static assets directly and falls back to `index.html` for any path that does not resolve to a file on disk — the standard SPA routing contract.\n\nThe module ships as a ready-to-use Docker base image. Deploying a SPA to a container requires a single `COPY` instruction. No nginx, no custom configuration, no index.html redirect logic to maintain.\n\n---\n\n## Container usage\n\n```dockerfile\nFROM code.nochebuena.dev/einherjar/spa-server:v1.0.0\nCOPY dist/ /srv/www/\n```\n\nThat is the complete Dockerfile for a production SPA container.\n\n---\n\n## Environment variables\n\n| Variable | Default | Description |\n|---|---|---|\n| `EINHERJAR_SPA_PORT` | `8080` | TCP port the server listens on |\n| `EINHERJAR_SPA_STATIC_DIR` | `/srv/www` | Path to the directory containing `index.html` and assets |\n| `EINHERJAR_LOG_LEVEL` | `INFO` | Log level: `DEBUG`, `INFO`, `WARN`, `ERROR` |\n\n---\n\n## Health endpoint\n\n`GET /health` returns `200 OK` with a JSON body consistent with all Einherjar health responses:\n\n```json\n{\"status\":\"UP\",\"components\":{}}\n```\n\n`503 Service Unavailable` is never returned — if the process is alive, it is healthy. Wire `/health` directly to your container liveness and readiness probes.\n\n---\n\n## Routing behaviour\n\n| Request | Result |\n|---|---|\n| `/app.js` — file exists | Served directly with correct `Content-Type` |\n| `/assets/logo.png` — file exists | Served directly |\n| `/dashboard` — no matching file | `index.html` served (SPA router handles it) |\n| `/` — directory | `index.html` served (directory listing is disabled) |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\nspa-server (contracts, core, stdlib only)\n```\n\nNo external dependencies beyond the Go standard library.\n\n---\n\n## Verification\n\n```bash\ncd spa-server/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *A shield wall holds because every warrior knows their position.*\n\u003e *The SPA asks only for the wall — not for every soldier's name.*\n",
|
||
"changelog": "# Changelog — einherjar/spa-server\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1. No code or API changes.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n"
|
||
},
|
||
{
|
||
"name": "storage-minio",
|
||
"importPath": "code.nochebuena.dev/einherjar/storage-minio",
|
||
"purpose": "The shield does not care who forged it. It holds what it is given and gives it back unchanged.",
|
||
"doc": "Package minio provides a MinIO/S3-compatible object storage component with\nlifecycle management and health check integration.\n\n# Lifecycle\n\nThe component follows the lifecycle.Component contract:\n - OnInit: initializes the minio-go SDK client from Config.\n - OnStart: verifies the configured bucket exists; creates it if absent.\n - OnStop: releases the client reference.\n\nAppend to a launcher, and wire a health endpoint (mc satisfies\n[observability.Checkable]):\n\n\tmc := minio.New(logger, cfg)\n\tlc.Append(mc) // lifecycle: OnInit → OnStart → OnStop\n\tsrv.Get(\"/health\", health.NewHandler(logger, mc).ServeHTTP)\n\n# Operations\n\nComponent embeds Provider, which covers the four most common bucket operations.\nFor operations outside that set, use Native() to access the underlying minio-go client:\n\n\t_, err := mc.PutObject(ctx, bucket, key, reader, size, miniogo.PutObjectOptions{})\n\tnative := mc.Native() // *miniogo.Client\n\n# Error Handling\n\nAll Provider methods translate minio-go errors to core/xerrors types at the boundary.\nThe standalone HandleError function provides the same translation for callers using Native():\n\n\txerrors.ErrNotFound — NoSuchBucket, NoSuchKey\n\txerrors.ErrPermissionDenied — AccessDenied, InvalidAccessKeyID\n\txerrors.ErrAlreadyExists — BucketAlreadyExists, BucketAlreadyOwnedByYou\n\txerrors.ErrInternal — all other errors\n\n# Configuration\n\n\tEINHERJAR_MINIO_ENDPOINT — required; MinIO server address (e.g. \"minio:9000\")\n\tEINHERJAR_MINIO_ACCESS_KEY — required\n\tEINHERJAR_MINIO_SECRET_KEY — required\n\tEINHERJAR_MINIO_BUCKET — required; bucket checked/created at startup\n\tEINHERJAR_MINIO_USE_SSL — optional; default false\n\tEINHERJAR_MINIO_REGION — optional; default \"us-east-1\"",
|
||
"goVersion": "1.26",
|
||
"dependsOn": [
|
||
"contracts",
|
||
"core"
|
||
],
|
||
"subPackages": [
|
||
{
|
||
"name": "",
|
||
"importPath": "code.nochebuena.dev/einherjar/storage-minio",
|
||
"doc": "Package minio provides a MinIO/S3-compatible object storage component with\nlifecycle management and health check integration.\n\n# Lifecycle\n\nThe component follows the lifecycle.Component contract:\n - OnInit: initializes the minio-go SDK client from Config.\n - OnStart: verifies the configured bucket exists; creates it if absent.\n - OnStop: releases the client reference.\n\nAppend to a launcher, and wire a health endpoint (mc satisfies\n[observability.Checkable]):\n\n\tmc := minio.New(logger, cfg)\n\tlc.Append(mc) // lifecycle: OnInit → OnStart → OnStop\n\tsrv.Get(\"/health\", health.NewHandler(logger, mc).ServeHTTP)\n\n# Operations\n\nComponent embeds Provider, which covers the four most common bucket operations.\nFor operations outside that set, use Native() to access the underlying minio-go client:\n\n\t_, err := mc.PutObject(ctx, bucket, key, reader, size, miniogo.PutObjectOptions{})\n\tnative := mc.Native() // *miniogo.Client\n\n# Error Handling\n\nAll Provider methods translate minio-go errors to core/xerrors types at the boundary.\nThe standalone HandleError function provides the same translation for callers using Native():\n\n\txerrors.ErrNotFound — NoSuchBucket, NoSuchKey\n\txerrors.ErrPermissionDenied — AccessDenied, InvalidAccessKeyID\n\txerrors.ErrAlreadyExists — BucketAlreadyExists, BucketAlreadyOwnedByYou\n\txerrors.ErrInternal — all other errors\n\n# Configuration\n\n\tEINHERJAR_MINIO_ENDPOINT — required; MinIO server address (e.g. \"minio:9000\")\n\tEINHERJAR_MINIO_ACCESS_KEY — required\n\tEINHERJAR_MINIO_SECRET_KEY — required\n\tEINHERJAR_MINIO_BUCKET — required; bucket checked/created at startup\n\tEINHERJAR_MINIO_USE_SSL — optional; default false\n\tEINHERJAR_MINIO_REGION — optional; default \"us-east-1\""
|
||
}
|
||
],
|
||
"symbols": [
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Component",
|
||
"signature": "type Component interface",
|
||
"doc": "Component bundles lifecycle management, health checks, and MinIO client operations.\nRegister with the launcher and health aggregator before starting.",
|
||
"file": "component.go",
|
||
"line": 12,
|
||
"methods": [
|
||
{
|
||
"signature": "lifecycle.Component"
|
||
},
|
||
{
|
||
"signature": "observability.Checkable"
|
||
},
|
||
{
|
||
"signature": "observability.Identifiable"
|
||
},
|
||
{
|
||
"signature": "Provider"
|
||
},
|
||
{
|
||
"name": "Native",
|
||
"signature": "Native() *miniogo.Client",
|
||
"doc": "Native returns the underlying minio-go SDK client for operations not covered by Provider."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "New",
|
||
"signature": "func New(logger logging.Logger, cfg Config) Component",
|
||
"doc": "New returns a Component backed by the given config.\nRegister the returned value with the launcher and health aggregator before starting.",
|
||
"file": "new.go",
|
||
"line": 22
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "Config",
|
||
"signature": "type Config struct",
|
||
"doc": "Config holds MinIO connection settings. Required fields must be supplied\nby the caller; optional fields have production-safe defaults via DefaultConfig.",
|
||
"file": "config.go",
|
||
"line": 7,
|
||
"fields": [
|
||
{
|
||
"name": "Endpoint",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_MINIO_ENDPOINT,required\""
|
||
},
|
||
{
|
||
"name": "AccessKey",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_MINIO_ACCESS_KEY,required\""
|
||
},
|
||
{
|
||
"name": "SecretKey",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_MINIO_SECRET_KEY,required\""
|
||
},
|
||
{
|
||
"name": "UseSSL",
|
||
"type": "bool",
|
||
"tag": "env:\"EINHERJAR_MINIO_USE_SSL\" envDefault:\"false\""
|
||
},
|
||
{
|
||
"name": "Bucket",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_MINIO_BUCKET,required\""
|
||
},
|
||
{
|
||
"name": "Region",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_MINIO_REGION\" envDefault:\"us-east-1\"",
|
||
"doc": "Region defaults to \"us-east-1\" — the region MinIO uses by default on self-hosted\ndeployments. Setting a non-empty region bypasses per-request region detection."
|
||
},
|
||
{
|
||
"name": "Transport",
|
||
"type": "http.RoundTripper",
|
||
"tag": "env:\"-\"",
|
||
"doc": "Transport is used for testing only. Nil uses the minio-go default transport."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "DefaultConfig",
|
||
"signature": "func DefaultConfig() Config",
|
||
"doc": "DefaultConfig returns a Config with all optional fields set to production-safe\ndefaults. Callers must supply Endpoint, AccessKey, SecretKey, and Bucket.",
|
||
"file": "config.go",
|
||
"line": 22
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Provider",
|
||
"signature": "type Provider interface",
|
||
"doc": "Provider is the MinIO operation interface for the most common bucket operations.\nInject it into repositories; call Native() on Component for SDK operations not covered here.",
|
||
"file": "provider.go",
|
||
"line": 14,
|
||
"methods": [
|
||
{
|
||
"name": "PutObject",
|
||
"signature": "PutObject(ctx context.Context, bucket, key string, reader io.Reader, size int64, opts miniogo.PutObjectOptions) (miniogo.UploadInfo, error)",
|
||
"doc": "PutObject uploads an object to the given bucket and key."
|
||
},
|
||
{
|
||
"name": "RemoveObject",
|
||
"signature": "RemoveObject(ctx context.Context, bucket, key string, opts miniogo.RemoveObjectOptions) error",
|
||
"doc": "RemoveObject deletes an object from the given bucket and key."
|
||
},
|
||
{
|
||
"name": "GetObject",
|
||
"signature": "GetObject(ctx context.Context, bucket, key string, opts miniogo.GetObjectOptions) (*miniogo.Object, error)",
|
||
"doc": "GetObject downloads an object and returns it as a readable stream.\nThe caller must close the returned object after reading."
|
||
},
|
||
{
|
||
"name": "PresignedGetObject",
|
||
"signature": "PresignedGetObject(ctx context.Context, bucket, key string, expires time.Duration, reqParams url.Values) (*url.URL, error)",
|
||
"doc": "PresignedGetObject returns a pre-signed URL for downloading an object."
|
||
},
|
||
{
|
||
"name": "HandleError",
|
||
"signature": "HandleError(err error) error",
|
||
"doc": "HandleError maps a minio-go error to a typed xerrors value."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "minioImpl",
|
||
"signature": "type minioImpl struct",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 26,
|
||
"fields": [
|
||
{
|
||
"name": "cfg",
|
||
"type": "Config"
|
||
},
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
},
|
||
{
|
||
"name": "mc",
|
||
"type": "*miniogo.Client"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "minioImpl.GetObject",
|
||
"signature": "func (c *minioImpl) GetObject(ctx context.Context, bucket, key string, opts miniogo.GetObjectOptions) (*miniogo.Object, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 99
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "minioImpl.HandleError",
|
||
"signature": "func (c *minioImpl) HandleError(err error) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 115
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "minioImpl.HealthCheck",
|
||
"signature": "func (c *minioImpl) HealthCheck(ctx context.Context) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 76
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "minioImpl.ModulePath",
|
||
"signature": "func (m *minioImpl) ModulePath() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 7
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "minioImpl.ModuleVersion",
|
||
"signature": "func (m *minioImpl) ModuleVersion() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 9
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "minioImpl.Name",
|
||
"signature": "func (c *minioImpl) Name() string",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 72
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "minioImpl.Native",
|
||
"signature": "func (c *minioImpl) Native() *miniogo.Client",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 74
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "minioImpl.OnInit",
|
||
"signature": "func (c *minioImpl) OnInit() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 32
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "minioImpl.OnStart",
|
||
"signature": "func (c *minioImpl) OnStart() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 47
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "minioImpl.OnStop",
|
||
"signature": "func (c *minioImpl) OnStop() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 67
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "minioImpl.PresignedGetObject",
|
||
"signature": "func (c *minioImpl) PresignedGetObject(ctx context.Context, bucket, key string, expires time.Duration, reqParams url.Values) (*url.URL, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 107
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "minioImpl.Priority",
|
||
"signature": "func (c *minioImpl) Priority() observability.Level",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 73
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "minioImpl.PutObject",
|
||
"signature": "func (c *minioImpl) PutObject(ctx context.Context, bucket, key string, reader io.Reader, size int64, opts miniogo.PutObjectOptions) (miniogo.UploadInfo, error)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 87
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "minioImpl.RemoveObject",
|
||
"signature": "func (c *minioImpl) RemoveObject(ctx context.Context, bucket, key string, opts miniogo.RemoveObjectOptions) error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 95
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "HandleError",
|
||
"signature": "func HandleError(err error) error",
|
||
"doc": "HandleError maps minio-go errors to xerrors types.\nAlso available as client.HandleError(err).",
|
||
"file": "errors.go",
|
||
"line": 11
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "const",
|
||
"name": "modulePath",
|
||
"signature": "const modulePath = \"code.nochebuena.dev/einherjar/storage-minio\"",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 5
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"kind": "var",
|
||
"name": "_",
|
||
"signature": "var _ observability.Identifiable = (*minioImpl)(nil)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 18
|
||
}
|
||
],
|
||
"adrs": null,
|
||
"examples": [
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"title": "Setup",
|
||
"code": "import storageminio \"code.nochebuena.dev/einherjar/storage-minio\"\n\ns := storageminio.New(logger, storageminio.DefaultConfig())\nlc.Append(s) // OnInit connects; OnStop is a no-op (stateless client)\n// s is observability.Checkable (BucketExists check, LevelCritical):\nsrv.Get(\"/health\", health.NewHandler(logger, s).ServeHTTP)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"title": "Uploading",
|
||
"code": "import miniogo \"github.com/minio/minio-go/v7\"\n\ninfo, err := s.PutObject(ctx, \"my-bucket\", \"uploads/photo.jpg\", reader, size, miniogo.PutObjectOptions{\n ContentType: \"image/jpeg\",\n})",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"title": "Downloading",
|
||
"code": "obj, err := s.GetObject(ctx, \"my-bucket\", \"uploads/photo.jpg\", miniogo.GetObjectOptions{})\nif err != nil {\n return s.HandleError(err)\n}\ndefer obj.Close()",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"title": "Presigned URL (time-limited public access)",
|
||
"code": "url, err := s.PresignedGetObject(ctx, \"my-bucket\", \"uploads/photo.jpg\", 15*time.Minute, nil)\n// url is a *url.URL — call url.String() to get the string form",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"title": "Deleting",
|
||
"code": "err := s.RemoveObject(ctx, \"my-bucket\", \"uploads/photo.jpg\", miniogo.RemoveObjectOptions{})",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"title": "Native escape hatch",
|
||
"code": "native := s.Native() // *miniogo.Client",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"title": "Error handling",
|
||
"code": "if err := s.HandleError(someErr); err != nil {\n // minio-go error responses mapped to xerrors:\n // NoSuchKey / NoSuchBucket → ErrNotFound\n // AccessDenied → ErrPermissionDenied\n // context.Canceled → ErrCancelled\n // context.DeadlineExceeded → ErrDeadlineExceeded\n}",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"title": "Dependency graph",
|
||
"code": "contracts (zero dependencies)\n ↑\n core\n ↑\nstorage-minio (contracts, core, minio-go/v7)\n ↑\n your app",
|
||
"language": ""
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"subPackage": "",
|
||
"title": "Verification",
|
||
"code": "cd storage-minio/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .",
|
||
"language": "bash"
|
||
}
|
||
],
|
||
"compliance": {
|
||
"interfaceAsserts": [
|
||
{
|
||
"module": "storage-minio",
|
||
"interface": "lifecycle.Component",
|
||
"impl": "(Component)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 25
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"interface": "observability.Checkable",
|
||
"impl": "(Component)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 26
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"interface": "Provider",
|
||
"impl": "(Component)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 27
|
||
}
|
||
],
|
||
"tests": [
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestAtMostOneExportedTypePerFile",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 31
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestDefaultConfig_OptionalFields",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 70
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestHandleError_Nil",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 90
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestHandleError_NoSuchBucket",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 96
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestHandleError_NoSuchKey",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 100
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestHandleError_AccessDenied",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 104
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestHandleError_InvalidAccessKeyID",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 108
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestHandleError_BucketAlreadyExists",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 112
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestHandleError_BucketAlreadyOwnedByYou",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 116
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestHandleError_Unknown",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 120
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestNew_NotNil",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 126
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestComponent_Name",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 132
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestComponent_Priority",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 139
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestComponent_OnStop_NilClient",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 148
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestComponent_HealthCheck_NilClient",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 155
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestComponent_HandleError_Passthrough",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 162
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestComponent_OnInit_And_Native",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 171
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestComponent_OnStart_BucketExists",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 184
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestComponent_OnStart_BucketMissing",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 204
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestComponent_OnStart_BucketError",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 228
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestComponent_HealthCheck_Unreachable",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 243
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestComponent_PutObject_Success",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 268
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestComponent_RemoveObject_Success",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 281
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestComponent_GetObject_Success",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 291
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestComponent_PresignedGetObject_Success",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 309
|
||
},
|
||
{
|
||
"module": "storage-minio",
|
||
"name": "TestComponent_OnStop_NilsClient",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 321
|
||
}
|
||
]
|
||
},
|
||
"readme": "# einherjar/storage-minio\n\n[](https://code.nochebuena.dev/einherjar/storage-minio)\n[](LICENSE)\n[](https://go.dev)\n[]()\n\n\u003e The shield does not care who forged it. It holds what it is given and gives it back unchanged.\n\n`code.nochebuena.dev/einherjar/storage-minio` is the MinIO/S3 object storage component of the Einherjar framework. It wraps `minio-go/v7` behind a lifecycle-aware `Component` with four common operations — upload, download, delete, and presigned URLs. For anything beyond that scope, `Native()` returns the raw `*miniogo.Client`.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport storageminio \"code.nochebuena.dev/einherjar/storage-minio\"\n\ns := storageminio.New(logger, storageminio.DefaultConfig())\nlc.Append(s) // OnInit connects; OnStop is a no-op (stateless client)\n// s is observability.Checkable (BucketExists check, LevelCritical):\nsrv.Get(\"/health\", health.NewHandler(logger, s).ServeHTTP)\n```\n\n### Uploading\n\n```go\nimport miniogo \"github.com/minio/minio-go/v7\"\n\ninfo, err := s.PutObject(ctx, \"my-bucket\", \"uploads/photo.jpg\", reader, size, miniogo.PutObjectOptions{\n ContentType: \"image/jpeg\",\n})\n```\n\n### Downloading\n\n```go\nobj, err := s.GetObject(ctx, \"my-bucket\", \"uploads/photo.jpg\", miniogo.GetObjectOptions{})\nif err != nil {\n return s.HandleError(err)\n}\ndefer obj.Close()\n```\n\n### Presigned URL (time-limited public access)\n\n```go\nurl, err := s.PresignedGetObject(ctx, \"my-bucket\", \"uploads/photo.jpg\", 15*time.Minute, nil)\n// url is a *url.URL — call url.String() to get the string form\n```\n\n### Deleting\n\n```go\nerr := s.RemoveObject(ctx, \"my-bucket\", \"uploads/photo.jpg\", miniogo.RemoveObjectOptions{})\n```\n\n### Native escape hatch\n\nFor multipart uploads, bucket management, or any operation not in `Provider`, use the raw client:\n\n```go\nnative := s.Native() // *miniogo.Client\n```\n\nCallers that use `Native()` must import `github.com/minio/minio-go/v7` directly.\n\n### Error handling\n\n```go\nif err := s.HandleError(someErr); err != nil {\n // minio-go error responses mapped to xerrors:\n // NoSuchKey / NoSuchBucket → ErrNotFound\n // AccessDenied → ErrPermissionDenied\n // context.Canceled → ErrCancelled\n // context.DeadlineExceeded → ErrDeadlineExceeded\n}\n```\n\n`HandleError` is also available as a package-level function: `storageminio.HandleError(err)`.\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_MINIO_ENDPOINT` | Yes | — | MinIO/S3 endpoint (host:port or domain) |\n| `EINHERJAR_MINIO_ACCESS_KEY` | Yes | — | Access key ID |\n| `EINHERJAR_MINIO_SECRET_KEY` | Yes | — | Secret access key |\n| `EINHERJAR_MINIO_BUCKET` | Yes | — | Default bucket for health check |\n| `EINHERJAR_MINIO_USE_SSL` | No | `false` | Use TLS |\n| `EINHERJAR_MINIO_REGION` | No | `us-east-1` | Bucket region |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\nstorage-minio (contracts, core, minio-go/v7)\n ↑\n your app\n```\n\n---\n\n## Verification\n\n```bash\ncd storage-minio/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *The artifact survives the battle that created it.*\n\u003e *Store it well. Someone will need it after you are gone.*\n",
|
||
"changelog": "# Changelog — einherjar/storage-minio\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n"
|
||
},
|
||
{
|
||
"name": "telemetry",
|
||
"importPath": "code.nochebuena.dev/einherjar/telemetry",
|
||
"purpose": "Huginn and Muninn fly each day over the world. They see everything. They report back.",
|
||
"doc": "Package telemetry bootstraps the OpenTelemetry SDK for Einherjar applications.\n\n# Overview\n\nThere are two bootstrap functions — one for production (OTLP over gRPC) and one\nfor local development (structured log output). Both set the three OTel global\nproviders so that all starters using otel.Tracer / otel.Meter / global.Logger\nauto-instrument without any code changes.\n\nThis package is app-only: import it only from main packages. Never import it\nfrom a starter or library — starters use only the OTel API, which is a zero-cost\nno-op until a real SDK is wired up here.\n\n# Production: OTLP over gRPC\n\n[New] connects to a Grafana Alloy (or any OTLP-compatible) collector and\nexports traces → Tempo, metrics → Mimir, and logs → Loki.\n\n\tfunc main() {\n\t ctx := context.Background()\n\n\t shutdown, err := telemetry.New(ctx, telemetry.Config{\n\t ServiceName: \"order-service\",\n\t ServiceVersion: \"1.4.2\",\n\t Environment: \"production\",\n\t OTLPEndpoint: \"alloy:4317\",\n\t OTLPInsecure: false,\n\t })\n\t if err != nil {\n\t log.Fatalf(\"telemetry: %v\", err)\n\t }\n\t defer shutdown(ctx)\n\n\t // Place the defer before lc.Run() so shutdown fires after the launcher\n\t // stops, before the process exits.\n\t}\n\n# Local Development: console mode\n\n[NewConsole] routes all three signals through a [logging.Logger] as structured\nlog lines. No collector is required — spans, metrics, and OTel log records appear\ninline with your application logs.\n\n\tfunc main() {\n\t ctx := context.Background()\n\t logger := logz.New(logz.Config{})\n\n\t shutdown, err := telemetry.NewConsole(ctx, logger, telemetry.ConsoleConfig{\n\t ServiceName: \"order-service\",\n\t })\n\t if err != nil {\n\t log.Fatalf(\"telemetry: %v\", err)\n\t }\n\t defer shutdown(ctx)\n\t}\n\n# Avoiding the slog feedback loop\n\nlogz is backed by slog. The OTel ecosystem provides a slog bridge\n(go.opentelemetry.io/contrib/bridges/otelslog) that forwards slog records into\nthe OTel log API. Do NOT use that bridge together with [NewConsole].\n\nThe loop is:\n\n\tslog.Info(\"msg\")\n\t → OTel log API (via slog bridge)\n\t → logLogExporter.Export()\n\t → logger.Info(\"otel: log\", ...) ← this is slog again\n\t → OTel log API (via slog bridge)\n\t → ... ∞\n\nThe slog bridge is safe with [New] because the OTLP exporter sends records over\nthe network — it never calls back into slog. The loop only occurs with [NewConsole]\nbecause its log exporter writes back to the same logger that feeds it.\n\nRule of thumb:\n - [New] + slog bridge: safe ✓\n - [NewConsole] + slog bridge: feedback loop ✗\n - [NewConsole] without slog bridge: safe ✓",
|
||
"goVersion": "1.26",
|
||
"dependsOn": [
|
||
"contracts",
|
||
"core"
|
||
],
|
||
"subPackages": [
|
||
{
|
||
"name": "",
|
||
"importPath": "code.nochebuena.dev/einherjar/telemetry",
|
||
"doc": "Package telemetry bootstraps the OpenTelemetry SDK for Einherjar applications.\n\n# Overview\n\nThere are two bootstrap functions — one for production (OTLP over gRPC) and one\nfor local development (structured log output). Both set the three OTel global\nproviders so that all starters using otel.Tracer / otel.Meter / global.Logger\nauto-instrument without any code changes.\n\nThis package is app-only: import it only from main packages. Never import it\nfrom a starter or library — starters use only the OTel API, which is a zero-cost\nno-op until a real SDK is wired up here.\n\n# Production: OTLP over gRPC\n\n[New] connects to a Grafana Alloy (or any OTLP-compatible) collector and\nexports traces → Tempo, metrics → Mimir, and logs → Loki.\n\n\tfunc main() {\n\t ctx := context.Background()\n\n\t shutdown, err := telemetry.New(ctx, telemetry.Config{\n\t ServiceName: \"order-service\",\n\t ServiceVersion: \"1.4.2\",\n\t Environment: \"production\",\n\t OTLPEndpoint: \"alloy:4317\",\n\t OTLPInsecure: false,\n\t })\n\t if err != nil {\n\t log.Fatalf(\"telemetry: %v\", err)\n\t }\n\t defer shutdown(ctx)\n\n\t // Place the defer before lc.Run() so shutdown fires after the launcher\n\t // stops, before the process exits.\n\t}\n\n# Local Development: console mode\n\n[NewConsole] routes all three signals through a [logging.Logger] as structured\nlog lines. No collector is required — spans, metrics, and OTel log records appear\ninline with your application logs.\n\n\tfunc main() {\n\t ctx := context.Background()\n\t logger := logz.New(logz.Config{})\n\n\t shutdown, err := telemetry.NewConsole(ctx, logger, telemetry.ConsoleConfig{\n\t ServiceName: \"order-service\",\n\t })\n\t if err != nil {\n\t log.Fatalf(\"telemetry: %v\", err)\n\t }\n\t defer shutdown(ctx)\n\t}\n\n# Avoiding the slog feedback loop\n\nlogz is backed by slog. The OTel ecosystem provides a slog bridge\n(go.opentelemetry.io/contrib/bridges/otelslog) that forwards slog records into\nthe OTel log API. Do NOT use that bridge together with [NewConsole].\n\nThe loop is:\n\n\tslog.Info(\"msg\")\n\t → OTel log API (via slog bridge)\n\t → logLogExporter.Export()\n\t → logger.Info(\"otel: log\", ...) ← this is slog again\n\t → OTel log API (via slog bridge)\n\t → ... ∞\n\nThe slog bridge is safe with [New] because the OTLP exporter sends records over\nthe network — it never calls back into slog. The loop only occurs with [NewConsole]\nbecause its log exporter writes back to the same logger that feeds it.\n\nRule of thumb:\n - [New] + slog bridge: safe ✓\n - [NewConsole] + slog bridge: feedback loop ✗\n - [NewConsole] without slog bridge: safe ✓"
|
||
}
|
||
],
|
||
"symbols": [
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "Config",
|
||
"signature": "type Config struct",
|
||
"doc": "Config holds OTel bootstrap configuration.",
|
||
"file": "config.go",
|
||
"line": 4,
|
||
"fields": [
|
||
{
|
||
"name": "ServiceName",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_OTEL_SERVICE_NAME,required\"",
|
||
"doc": "ServiceName identifies the service in traces, metrics, and logs."
|
||
},
|
||
{
|
||
"name": "ServiceVersion",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_OTEL_SERVICE_VERSION\" envDefault:\"unknown\"",
|
||
"doc": "ServiceVersion is the deployed version (e.g. \"1.4.2\")."
|
||
},
|
||
{
|
||
"name": "Environment",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_OTEL_ENVIRONMENT\" envDefault:\"development\"",
|
||
"doc": "Environment is the deployment environment (e.g. \"production\", \"staging\")."
|
||
},
|
||
{
|
||
"name": "OTLPEndpoint",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_OTEL_EXPORTER_ENDPOINT,required\"",
|
||
"doc": "OTLPEndpoint is the OTLP gRPC collector address (e.g. \"alloy:4317\")."
|
||
},
|
||
{
|
||
"name": "OTLPInsecure",
|
||
"type": "bool",
|
||
"tag": "env:\"EINHERJAR_OTEL_EXPORTER_INSECURE\" envDefault:\"false\"",
|
||
"doc": "OTLPInsecure disables TLS for the OTLP connection. Set true in development."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "DefaultConfig",
|
||
"signature": "func DefaultConfig() Config",
|
||
"doc": "DefaultConfig returns a Config with optional fields set to production-safe defaults.\nCallers must supply ServiceName and OTLPEndpoint.",
|
||
"file": "config.go",
|
||
"line": 19
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "ConsoleConfig",
|
||
"signature": "type ConsoleConfig struct",
|
||
"doc": "ConsoleConfig holds the minimum OTel configuration needed for console/dev mode.\nOnly service identity fields are required — no OTLP endpoint.",
|
||
"file": "console_config.go",
|
||
"line": 5,
|
||
"fields": [
|
||
{
|
||
"name": "ServiceName",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_OTEL_SERVICE_NAME,required\""
|
||
},
|
||
{
|
||
"name": "ServiceVersion",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_OTEL_SERVICE_VERSION\" envDefault:\"unknown\""
|
||
},
|
||
{
|
||
"name": "Environment",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_OTEL_ENVIRONMENT\" envDefault:\"development\""
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "DefaultConsoleConfig",
|
||
"signature": "func DefaultConsoleConfig() ConsoleConfig",
|
||
"doc": "DefaultConsoleConfig returns a ConsoleConfig with optional fields set to defaults.\nCallers must supply ServiceName.",
|
||
"file": "console_config.go",
|
||
"line": 13
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "logLogExporter",
|
||
"signature": "type logLogExporter struct",
|
||
"doc": "",
|
||
"file": "console.go",
|
||
"line": 157,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "logLogExporter.Export",
|
||
"signature": "func (e *logLogExporter) Export(_ context.Context, records []sdklog.Record) error",
|
||
"doc": "",
|
||
"file": "console.go",
|
||
"line": 159
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "logLogExporter.ForceFlush",
|
||
"signature": "func (e *logLogExporter) ForceFlush(_ context.Context) error",
|
||
"doc": "",
|
||
"file": "console.go",
|
||
"line": 175
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "logLogExporter.Shutdown",
|
||
"signature": "func (e *logLogExporter) Shutdown(_ context.Context) error",
|
||
"doc": "",
|
||
"file": "console.go",
|
||
"line": 176
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "logMetricExporter",
|
||
"signature": "type logMetricExporter struct",
|
||
"doc": "",
|
||
"file": "console.go",
|
||
"line": 104,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "logMetricExporter.Aggregation",
|
||
"signature": "func (e *logMetricExporter) Aggregation(k sdkmetric.InstrumentKind) sdkmetric.Aggregation",
|
||
"doc": "",
|
||
"file": "console.go",
|
||
"line": 110
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "logMetricExporter.Export",
|
||
"signature": "func (e *logMetricExporter) Export(_ context.Context, rm *metricdata.ResourceMetrics) error",
|
||
"doc": "",
|
||
"file": "console.go",
|
||
"line": 114
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "logMetricExporter.ForceFlush",
|
||
"signature": "func (e *logMetricExporter) ForceFlush(_ context.Context) error",
|
||
"doc": "",
|
||
"file": "console.go",
|
||
"line": 152
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "logMetricExporter.Shutdown",
|
||
"signature": "func (e *logMetricExporter) Shutdown(_ context.Context) error",
|
||
"doc": "",
|
||
"file": "console.go",
|
||
"line": 153
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "logMetricExporter.Temporality",
|
||
"signature": "func (e *logMetricExporter) Temporality(_ sdkmetric.InstrumentKind) metricdata.Temporality",
|
||
"doc": "",
|
||
"file": "console.go",
|
||
"line": 106
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "logMetricExporter.logMetric",
|
||
"signature": "func (e *logMetricExporter) logMetric(m metricdata.Metrics)",
|
||
"doc": "",
|
||
"file": "console.go",
|
||
"line": 123
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "logTraceExporter",
|
||
"signature": "type logTraceExporter struct",
|
||
"doc": "",
|
||
"file": "console.go",
|
||
"line": 83,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "logTraceExporter.ExportSpans",
|
||
"signature": "func (e *logTraceExporter) ExportSpans(_ context.Context, spans []sdktrace.ReadOnlySpan) error",
|
||
"doc": "",
|
||
"file": "console.go",
|
||
"line": 85
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "logTraceExporter.Shutdown",
|
||
"signature": "func (e *logTraceExporter) Shutdown(_ context.Context) error",
|
||
"doc": "",
|
||
"file": "console.go",
|
||
"line": 100
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "moduleID",
|
||
"signature": "type moduleID struct",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 14
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "moduleID.ModulePath",
|
||
"signature": "func (m *moduleID) ModulePath() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 18
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "moduleID.ModuleVersion",
|
||
"signature": "func (m *moduleID) ModuleVersion() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 20
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "providerErr",
|
||
"signature": "type providerErr struct",
|
||
"doc": "providerErr labels a shutdown error with the provider name.",
|
||
"file": "telemetry.go",
|
||
"line": 112,
|
||
"fields": [
|
||
{
|
||
"name": "provider",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"name": "err",
|
||
"type": "error"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "providerErr.Error",
|
||
"signature": "func (e *providerErr) Error() string",
|
||
"doc": "",
|
||
"file": "telemetry.go",
|
||
"line": 117
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "providerErr.Unwrap",
|
||
"signature": "func (e *providerErr) Unwrap() error",
|
||
"doc": "",
|
||
"file": "telemetry.go",
|
||
"line": 121
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "New",
|
||
"signature": "func New(ctx context.Context, cfg Config) (func(context.Context) error, error)",
|
||
"doc": "New bootstraps the full OTel SDK:\n - TracerProvider → OTLP gRPC → Grafana Alloy → Tempo\n - MeterProvider → OTLP gRPC → Grafana Alloy → Mimir\n - LoggerProvider → OTLP gRPC → Grafana Alloy → Loki\n\nSets the three OTel globals so all starters using the global API\nauto-instrument without importing this module.\n\nThe returned shutdown function flushes all exporters and must be called\nbefore process exit (defer it in main or wire it into the launcher).\nReturns (shutdown, nil) on success, (nil, err) on failure.",
|
||
"file": "telemetry.go",
|
||
"line": 34
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "NewConsole",
|
||
"signature": "func NewConsole(ctx context.Context, logger logging.Logger, cfg ConsoleConfig) (func(context.Context) error, error)",
|
||
"doc": "NewConsole bootstraps the OTel SDK with logger-backed exporters for local development.\nAll signals (traces, metrics, OTel log records) are emitted as structured log lines\ninstead of being sent to a collector. Drop-in alternative to [New].\n\nWarning: do not use the OTel slog bridge (otelslog) together with NewConsole.\nThe bridge routes slog records into the OTel log API; logLogExporter then writes\nthem back to the same logger — creating an infinite feedback loop. See package\ndocumentation for the full explanation and safe usage patterns.",
|
||
"file": "console.go",
|
||
"line": 29
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "newResource",
|
||
"signature": "func newResource(cfg Config) *resource.Resource",
|
||
"doc": "newResource builds an OTel resource with service identity and environment attributes.",
|
||
"file": "telemetry.go",
|
||
"line": 124
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "const",
|
||
"name": "modulePath",
|
||
"signature": "const modulePath = \"code.nochebuena.dev/einherjar/telemetry\"",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 16
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"kind": "var",
|
||
"name": "Module",
|
||
"signature": "var Module observability.Identifiable = \u0026moduleID{}",
|
||
"doc": "Module identifies this package to observability systems.\ntelemetry bootstraps before the launcher and is not registered as a lifecycle\ncomponent. Register Module manually with any version registry if needed.",
|
||
"file": "identifiable.go",
|
||
"line": 12
|
||
}
|
||
],
|
||
"adrs": null,
|
||
"examples": [
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"title": "Production (OTLP/gRPC)",
|
||
"code": "import (\n \"code.nochebuena.dev/einherjar/telemetry\"\n \"code.nochebuena.dev/einherjar/core/launcher\"\n \"code.nochebuena.dev/einherjar/core/logz\"\n)\n\nctx := context.Background()\nlogger := logz.New(logz.Config{JSON: true})\n\n// Initialize telemetry BEFORE the launcher.\n// Defer the shutdown BEFORE launcher.Run() so it fires after the launcher stops.\nshutdown, err := telemetry.New(ctx, telemetry.DefaultConfig())\nif err != nil {\n logger.Error(\"telemetry init failed\", err)\n os.Exit(1)\n}\ndefer shutdown(ctx)\n\nlc := launcher.New(logger)\nlc.Append(db, cache, srv)\nlc.Run()",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"title": "Development (console / structured log output)",
|
||
"code": "shutdown, err := telemetry.NewConsole(ctx, logger, telemetry.DefaultConsoleConfig())\nif err != nil {\n logger.Error(\"telemetry init failed\", err)\n os.Exit(1)\n}\ndefer shutdown(ctx)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"title": "Dependency graph",
|
||
"code": "contracts (zero dependencies)\n ↑\n core\n ↑\ntelemetry (contracts, core, otel SDK + OTLP exporters)\n ↑\n your app (initialized before launcher.Run)",
|
||
"language": ""
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"subPackage": "",
|
||
"title": "Verification",
|
||
"code": "cd telemetry/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .",
|
||
"language": "bash"
|
||
}
|
||
],
|
||
"compliance": {
|
||
"interfaceAsserts": [],
|
||
"tests": [
|
||
{
|
||
"module": "telemetry",
|
||
"name": "TestAtMostOneExportedTypePerFile",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 26
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"name": "TestDefaultConfig_OptionalFields",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 67
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"name": "TestDefaultConsoleConfig_OptionalFields",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 80
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"name": "TestNewResource_Fields",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 94
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"name": "TestNewResource_MergesWithDefault",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 120
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"name": "TestNew_ShutdownCallable",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 164
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"name": "TestNew_SetsGlobalTracerProvider",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 180
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"name": "TestNew_SetsGlobalMeterProvider",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 199
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"name": "TestNewConsole_ShutdownCallable",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 279
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"name": "TestNewConsole_SetsGlobalTracerProvider",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 294
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"name": "TestNewConsole_SetsGlobalMeterProvider",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 313
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"name": "TestNewConsole_ExportsSpan",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 332
|
||
},
|
||
{
|
||
"module": "telemetry",
|
||
"name": "TestNewConsole_ExportsMetric",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 355
|
||
}
|
||
]
|
||
},
|
||
"readme": "# einherjar/telemetry\n\n[](https://code.nochebuena.dev/einherjar/telemetry)\n[](LICENSE)\n[](https://go.dev)\n\n\u003e Huginn and Muninn fly each day over the world. They see everything. They report back.\n\n`code.nochebuena.dev/einherjar/telemetry` is the OpenTelemetry bootstrap component of the Einherjar framework. It initializes traces, metrics, and structured logs via OTLP over gRPC — vendor-neutral, compatible with Grafana, Jaeger, Tempo, Datadog, and Honeycomb. A console mode is available for local development without a collector.\n\nTelemetry is **not** a `lifecycle.Component`. It must be initialized before the launcher and shut down after all components stop — the returned shutdown function handles this cleanly with a `defer`.\n\n---\n\n## Usage\n\n### Production (OTLP/gRPC)\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/telemetry\"\n \"code.nochebuena.dev/einherjar/core/launcher\"\n \"code.nochebuena.dev/einherjar/core/logz\"\n)\n\nctx := context.Background()\nlogger := logz.New(logz.Config{JSON: true})\n\n// Initialize telemetry BEFORE the launcher.\n// Defer the shutdown BEFORE launcher.Run() so it fires after the launcher stops.\nshutdown, err := telemetry.New(ctx, telemetry.DefaultConfig())\nif err != nil {\n logger.Error(\"telemetry init failed\", err)\n os.Exit(1)\n}\ndefer shutdown(ctx)\n\nlc := launcher.New(logger)\nlc.Append(db, cache, srv)\nlc.Run()\n```\n\n### Development (console / structured log output)\n\nNo collector required. Spans and metrics are emitted to the configured `logging.Logger`.\n\n```go\nshutdown, err := telemetry.NewConsole(ctx, logger, telemetry.DefaultConsoleConfig())\nif err != nil {\n logger.Error(\"telemetry init failed\", err)\n os.Exit(1)\n}\ndefer shutdown(ctx)\n```\n\n---\n\n## Environment variables\n\n### Production (`telemetry.New`)\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_OTEL_SERVICE_NAME` | Yes | — | Service name reported to the collector |\n| `EINHERJAR_OTEL_EXPORTER_ENDPOINT` | Yes | — | OTLP gRPC endpoint (e.g. `otel-collector:4317`) |\n| `EINHERJAR_OTEL_SERVICE_VERSION` | No | `unknown` | Service version tag |\n| `EINHERJAR_OTEL_ENVIRONMENT` | No | `development` | Deployment environment tag |\n| `EINHERJAR_OTEL_EXPORTER_INSECURE` | No | `false` | Disable TLS for the exporter (dev/local) |\n\n### Development (`telemetry.NewConsole`)\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_OTEL_SERVICE_NAME` | Yes | — | Service name |\n| `EINHERJAR_OTEL_SERVICE_VERSION` | No | `unknown` | Service version tag |\n| `EINHERJAR_OTEL_ENVIRONMENT` | No | `development` | Deployment environment tag |\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n core\n ↑\ntelemetry (contracts, core, otel SDK + OTLP exporters)\n ↑\n your app (initialized before launcher.Run)\n```\n\n---\n\n## Verification\n\n```bash\ncd telemetry/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *Odin gave an eye for wisdom.*\n\u003e *Observability is the eye that watches the living system.*\n",
|
||
"changelog": "# Changelog — einherjar/telemetry\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`, \\`core\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`, `core`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n"
|
||
},
|
||
{
|
||
"name": "web",
|
||
"importPath": "code.nochebuena.dev/einherjar/web",
|
||
"purpose": "The gate is not a barrier. It is the point where the outside world meets order.",
|
||
"doc": "Package web provides the HTTP foundation for Einherjar services.\n\nThe root package exposes [New] — a factory that returns a lifecycle-managed\nchi server with the recommended middleware stack pre-applied (request ID,\npanic recovery, request logging). Import the sub-packages directly for full\ncontrol over individual components.\n\nSub-packages:\n\n - [code.nochebuena.dev/einherjar/web/server] — lifecycle-managed chi HTTP server\n - [code.nochebuena.dev/einherjar/web/mw] — transport middleware: recover, CORS, request ID, request logger, rate limiting\n - [code.nochebuena.dev/einherjar/web/httputil] — typed handler adapters and HTTP response helpers\n - [code.nochebuena.dev/einherjar/web/health] — concurrent health check handler\n\n# Choosing web.New vs server.New\n\nTwo tiers over the same underlying server:\n\n - [New] (web.New) — batteries-included. The recommended middleware stack is wired\n for you; CORS uses explicit origins from EINHERJAR_SERVER_CORS_ORIGINS. Use it for\n most services. It does NOT support allow-all CORS.\n - [code.nochebuena.dev/einherjar/web/server.New] — full control. You compose the\n middleware list yourself. Use it when you need a custom middleware order, a custom\n request-ID generator, or allow-all CORS in development ([mw.CORSAllowAll], gated by\n environment).\n\n# web.New — batteries included (explicit CORS origins)\n\n\tlogger := logz.New(logz.Config{JSON: true})\n\tlc := launcher.New(logger)\n\n\t// CORS from EINHERJAR_SERVER_CORS_ORIGINS (explicit origins; empty ⇒ CORS off + log).\n\tsrv := web.New(logger, web.Config{Server: cfg.Server})\n\tsrv.Get(\"/health\", health.NewHandler(logger, db, cache).ServeHTTP)\n\n\tlc.Append(srv)\n\tif err := lc.Run(); err != nil {\n\t logger.Error(\"launcher failed\", err)\n\t os.Exit(1)\n\t}\n\n# server.New — full control (allow-all CORS in dev)\n\nFor allow-all CORS in local development, gate it by environment and compose the\nstack yourself. mw.CORS panics on \"*\", so allow-all is [mw.CORSAllowAll], never a\n\"*\" in the origins list:\n\n\tvar corsMW func(http.Handler) http.Handler\n\tif strings.EqualFold(cfg.AppEnv, \"local\") {\n\t corsMW = mw.CORSAllowAll() // dev: any origin\n\t} else {\n\t corsMW = mw.CORS(cfg.Server.CORSOrigins) // prod: explicit origins from env\n\t}\n\tsrv := server.New(logger, cfg.Server, server.WithMiddleware(\n\t mw.Recover(logger), mw.RequestID(uuid.NewString), corsMW, mw.RequestLogger(logger),\n\t))",
|
||
"goVersion": "1.26",
|
||
"dependsOn": [
|
||
"contracts",
|
||
"core"
|
||
],
|
||
"subPackages": [
|
||
{
|
||
"name": "",
|
||
"importPath": "code.nochebuena.dev/einherjar/web",
|
||
"doc": "Package web provides the HTTP foundation for Einherjar services.\n\nThe root package exposes [New] — a factory that returns a lifecycle-managed\nchi server with the recommended middleware stack pre-applied (request ID,\npanic recovery, request logging). Import the sub-packages directly for full\ncontrol over individual components.\n\nSub-packages:\n\n - [code.nochebuena.dev/einherjar/web/server] — lifecycle-managed chi HTTP server\n - [code.nochebuena.dev/einherjar/web/mw] — transport middleware: recover, CORS, request ID, request logger, rate limiting\n - [code.nochebuena.dev/einherjar/web/httputil] — typed handler adapters and HTTP response helpers\n - [code.nochebuena.dev/einherjar/web/health] — concurrent health check handler\n\n# Choosing web.New vs server.New\n\nTwo tiers over the same underlying server:\n\n - [New] (web.New) — batteries-included. The recommended middleware stack is wired\n for you; CORS uses explicit origins from EINHERJAR_SERVER_CORS_ORIGINS. Use it for\n most services. It does NOT support allow-all CORS.\n - [code.nochebuena.dev/einherjar/web/server.New] — full control. You compose the\n middleware list yourself. Use it when you need a custom middleware order, a custom\n request-ID generator, or allow-all CORS in development ([mw.CORSAllowAll], gated by\n environment).\n\n# web.New — batteries included (explicit CORS origins)\n\n\tlogger := logz.New(logz.Config{JSON: true})\n\tlc := launcher.New(logger)\n\n\t// CORS from EINHERJAR_SERVER_CORS_ORIGINS (explicit origins; empty ⇒ CORS off + log).\n\tsrv := web.New(logger, web.Config{Server: cfg.Server})\n\tsrv.Get(\"/health\", health.NewHandler(logger, db, cache).ServeHTTP)\n\n\tlc.Append(srv)\n\tif err := lc.Run(); err != nil {\n\t logger.Error(\"launcher failed\", err)\n\t os.Exit(1)\n\t}\n\n# server.New — full control (allow-all CORS in dev)\n\nFor allow-all CORS in local development, gate it by environment and compose the\nstack yourself. mw.CORS panics on \"*\", so allow-all is [mw.CORSAllowAll], never a\n\"*\" in the origins list:\n\n\tvar corsMW func(http.Handler) http.Handler\n\tif strings.EqualFold(cfg.AppEnv, \"local\") {\n\t corsMW = mw.CORSAllowAll() // dev: any origin\n\t} else {\n\t corsMW = mw.CORS(cfg.Server.CORSOrigins) // prod: explicit origins from env\n\t}\n\tsrv := server.New(logger, cfg.Server, server.WithMiddleware(\n\t mw.Recover(logger), mw.RequestID(uuid.NewString), corsMW, mw.RequestLogger(logger),\n\t))"
|
||
},
|
||
{
|
||
"name": "health",
|
||
"importPath": "code.nochebuena.dev/einherjar/web/health",
|
||
"doc": "Package health provides a concurrent health check HTTP handler.\n\nThe handler runs all registered checks in parallel, collects results within\na configurable timeout, and returns a structured JSON response. It maps\ndirectly onto Kubernetes liveness and readiness probes.\n\nComponents register themselves by implementing\n[observability.Checkable] from einherjar/contracts — no adapter needed.\n\n - [observability.LevelCritical]: if DOWN, overall status is DOWN (503)\n - [observability.LevelDegraded]: if DOWN, overall status is DEGRADED (200)\n\n# Example\n\n\tsrv.Get(\"/health\", health.NewHandler(logger, db, cache, storage).ServeHTTP)\n\nOr with custom timeout:\n\n\tsrv.Get(\"/health\", health.NewHandlerWithConfig(logger,\n\t health.Config{CheckTimeout: 3 * time.Second},\n\t db, cache,\n\t).ServeHTTP)"
|
||
},
|
||
{
|
||
"name": "httputil",
|
||
"importPath": "code.nochebuena.dev/einherjar/web/httputil",
|
||
"doc": "Package httputil provides typed handler adapters and HTTP response helpers.\n\nHandler adapters eliminate HTTP boilerplate — business functions stay pure Go\nwith no knowledge of request parsing or response encoding. All errors flow\nthrough a centralized [Error] handler that logs once at the correct level and\nwrites a standardized JSON response body.\n\n# Typed handler adapters\n\n\ttype CreateUserReq struct {\n\t Email string `json:\"email\" validate:\"required,email\"`\n\t}\n\ttype CreateUserRes struct {\n\t ID string `json:\"id\"`\n\t}\n\n\tr.Post(\"/users\", httputil.Handle(v, logger, func(ctx context.Context, req CreateUserReq) (CreateUserRes, error) {\n\t u, err := svc.CreateUser(ctx, req.Email)\n\t if err != nil {\n\t return CreateUserRes{}, err // propagates to Error — logged once, correct HTTP status\n\t }\n\t return CreateUserRes{ID: u.ID}, nil\n\t}))\n\n# Centralized error handler\n\n[Error] is the single point of error processing for all handlers:\n - 5xx → Error level (logz auto-enriches with error_code and WithContext fields)\n - 4xx → Warn level (client mistake — not a server failure)\n - 499 → Info level (client cancelled the request intentionally)\n\nCall it directly from [HandlerFunc] when you need path parameters or custom logic:\n\n\tr.Get(\"/users/{id}\", httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {\n\t id := chi.URLParam(r, \"id\")\n\t u, err := svc.GetUser(r.Context(), id)\n\t if err != nil {\n\t httputil.Error(logger, w, r, err)\n\t return nil\n\t }\n\t httputil.JSON(w, http.StatusOK, u)\n\t return nil\n\t}).ServeHTTP)"
|
||
},
|
||
{
|
||
"name": "mw",
|
||
"importPath": "code.nochebuena.dev/einherjar/web/mw",
|
||
"doc": "Package mw provides transport-level HTTP middleware for Einherjar services.\n\nAll middleware functions return func(http.Handler) http.Handler and are\ncomposed via [server.WithMiddleware] or chi's Use method.\n\n# Recommended middleware order (outermost first)\n\n\tserver.WithMiddleware(\n\t mw.Recover(logger),\n\t mw.RequestID(uuid.NewString),\n\t mw.RequestLogger(logger),\n\t mw.CORS([]string{\"https://example.com\"}),\n\t)\n\n# Rate limiting\n\n\t// In-memory (default — no extra dependencies)\n\tstore := mw.NewInMemoryRateLimiterStore(100, 20)\n\tsrv.Use(mw.IPRateLimit(store, logger))\n\n\t// Distributed — swap store, middleware unchanged\n\tstore := valkeymw.NewRateLimiterStore(client, 100, 20)\n\tsrv.Use(mw.IPRateLimit(store, logger))"
|
||
},
|
||
{
|
||
"name": "server",
|
||
"importPath": "code.nochebuena.dev/einherjar/web/server",
|
||
"doc": "Package server provides a lifecycle-aware HTTP server for Einherjar services.\n\n[Server] embeds both [lifecycle.Component] and [chi.Router], so it plugs\ndirectly into [launcher.New] and exposes the full chi routing API.\n\nFor the happy path use [web.New], which pre-wires the recommended middleware\nstack (explicit-origin CORS included). Use this package directly when you need\nexplicit control over middleware order, a custom request-ID generator, or\nallow-all CORS in development.\n\n# CORS\n\n[Config.CORSOrigins] loads EINHERJAR_SERVER_CORS_ORIGINS. Gate allow-all by\nenvironment — mw.CORS panics on \"*\", so allow-all is [mw.CORSAllowAll], never a\nwildcard origin:\n\n\tvar corsMW func(http.Handler) http.Handler\n\tif strings.EqualFold(cfg.AppEnv, \"local\") {\n\t corsMW = mw.CORSAllowAll() // dev: any origin\n\t} else {\n\t corsMW = mw.CORS(cfg.Server.CORSOrigins) // prod: explicit origins\n\t}\n\n# Basic usage\n\n\tsrv := server.New(logger, server.Config{Port: 8080},\n\t server.WithMiddleware(\n\t mw.Recover(logger),\n\t mw.RequestID(myIDGenerator),\n\t mw.RequestLogger(logger),\n\t mw.CORS([]string{\"https://example.com\"}),\n\t ),\n\t)\n\n\tsrv.Get(\"/health\", health.NewHandler(logger, db).ServeHTTP)\n\n\tlc := launcher.New(logger)\n\tlc.Append(srv)\n\tlc.BeforeStart(func() error {\n\t srv.Mount(\"/v1\", apiRouter)\n\t return nil\n\t})\n\tif err := lc.Run(); err != nil {\n\t logger.Error(\"launcher failed\", err)\n\t os.Exit(1)\n\t}\n\n# Lifecycle\n\n[Server.OnInit] applies registered middleware to the router.\n[Server.OnStart] binds the TCP listener synchronously — a port conflict\nsurfaces immediately rather than silently dropping the server. Requests\nare served in a background goroutine.\n[Server.OnStop] performs a graceful shutdown within [Config.ShutdownTimeout].\n\n# Environment variables\n\n\tEINHERJAR_SERVER_HOST=0.0.0.0 bind address (default 0.0.0.0)\n\tEINHERJAR_SERVER_PORT=8080 listen port (default 8080)\n\tEINHERJAR_SERVER_READ_TIMEOUT=5s HTTP read timeout (default 5s)\n\tEINHERJAR_SERVER_WRITE_TIMEOUT=10s HTTP write timeout (default 10s)\n\tEINHERJAR_SERVER_IDLE_TIMEOUT=120s keep-alive idle timeout (default 120s)\n\tEINHERJAR_SERVER_SHUTDOWN_TIMEOUT=10s graceful shutdown budget (default 10s)\n\nPackage server provides a lifecycle-managed chi HTTP server.\n\nThe server implements [lifecycle.Component] from contracts, making it\ndirectly compatible with [launcher.New] without any adapter layer.\n\n# Example\n\n\tsrv := server.New(logger, server.Config{Port: 8080},\n\t server.WithMiddleware(\n\t mw.Recover(logger),\n\t mw.RequestID(uuid.NewString),\n\t mw.RequestLogger(logger),\n\t ),\n\t)\n\n\tlc := launcher.New(logger)\n\tlc.Append(srv)\n\tlc.BeforeStart(func() error {\n\t srv.Get(\"/health\", healthHandler)\n\t return nil\n\t})"
|
||
}
|
||
],
|
||
"symbols": [
|
||
{
|
||
"module": "web",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "Config",
|
||
"signature": "type Config struct",
|
||
"doc": "Config aggregates configuration for the web module. Server holds the HTTP server\nsettings, including the single source of truth for CORS: Server.CORSOrigins,\nloaded from EINHERJAR_SERVER_CORS_ORIGINS. To override origins from code (without\nthe env var), set Server.CORSOrigins directly before calling New.",
|
||
"file": "web.go",
|
||
"line": 17,
|
||
"fields": [
|
||
{
|
||
"name": "Server",
|
||
"type": "server.Config"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "New",
|
||
"signature": "func New(logger logging.Logger, cfg ...Config) server.Server",
|
||
"doc": "New creates a [server.Server] with the recommended middleware stack pre-applied:\n 1. Recover — catches panics, returns 500\n 2. RequestID — injects UUID v7 request ID (falls back to v4)\n 3. RequestLogger — logs method, path, status, latency\n 4. CORS — applied only when Server.CORSOrigins is non-empty (from\n EINHERJAR_SERVER_CORS_ORIGINS, or set in code before calling New)\n\nweb.New uses explicit origins only; it does NOT support allow-all. For\n[mw.CORSAllowAll] (development) or any custom middleware order, use [server.New]\ndirectly. When no origins are configured, CORS is off and a log line records it.",
|
||
"file": "web.go",
|
||
"line": 31
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "newRequestID",
|
||
"signature": "func newRequestID() string",
|
||
"doc": "",
|
||
"file": "web.go",
|
||
"line": 51
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "health",
|
||
"kind": "type",
|
||
"name": "ComponentStatus",
|
||
"signature": "type ComponentStatus struct",
|
||
"doc": "ComponentStatus is the health state of a single component in the JSON response.",
|
||
"file": "health/component_status.go",
|
||
"line": 4,
|
||
"fields": [
|
||
{
|
||
"name": "Status",
|
||
"type": "string",
|
||
"tag": "json:\"status\""
|
||
},
|
||
{
|
||
"name": "Latency",
|
||
"type": "string",
|
||
"tag": "json:\"latency,omitempty\""
|
||
},
|
||
{
|
||
"name": "Error",
|
||
"type": "string",
|
||
"tag": "json:\"error,omitempty\""
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "health",
|
||
"kind": "type",
|
||
"name": "Config",
|
||
"signature": "type Config struct",
|
||
"doc": "Config configures the health check handler.\nThe zero value is valid — a 5-second check timeout is applied.",
|
||
"file": "health/config.go",
|
||
"line": 7,
|
||
"fields": [
|
||
{
|
||
"name": "CheckTimeout",
|
||
"type": "time.Duration",
|
||
"tag": "env:\"EINHERJAR_HEALTH_CHECK_TIMEOUT\" envDefault:\"5s\""
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "health",
|
||
"kind": "type",
|
||
"name": "Response",
|
||
"signature": "type Response struct",
|
||
"doc": "Response is the JSON body returned by the health handler.\nStatus is one of \"UP\", \"DEGRADED\", or \"DOWN\".",
|
||
"file": "health/response.go",
|
||
"line": 5,
|
||
"fields": [
|
||
{
|
||
"name": "Status",
|
||
"type": "string",
|
||
"tag": "json:\"status\""
|
||
},
|
||
{
|
||
"name": "Components",
|
||
"type": "map[string]ComponentStatus",
|
||
"tag": "json:\"components\""
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "health",
|
||
"kind": "type",
|
||
"name": "checkResult",
|
||
"signature": "type checkResult struct",
|
||
"doc": "",
|
||
"file": "health/handler.go",
|
||
"line": 13,
|
||
"fields": [
|
||
{
|
||
"name": "name",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"name": "status",
|
||
"type": "ComponentStatus"
|
||
},
|
||
{
|
||
"name": "priority",
|
||
"type": "observability.Level"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "health",
|
||
"kind": "type",
|
||
"name": "handler",
|
||
"signature": "type handler struct",
|
||
"doc": "",
|
||
"file": "health/handler.go",
|
||
"line": 19,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
},
|
||
{
|
||
"name": "checks",
|
||
"type": "[]observability.Checkable"
|
||
},
|
||
{
|
||
"name": "timeout",
|
||
"type": "time.Duration"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "health",
|
||
"kind": "method",
|
||
"name": "handler.ServeHTTP",
|
||
"signature": "func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request)",
|
||
"doc": "",
|
||
"file": "health/handler.go",
|
||
"line": 42
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "health",
|
||
"kind": "func",
|
||
"name": "NewHandler",
|
||
"signature": "func NewHandler(logger logging.Logger, checks ...observability.Checkable) http.Handler",
|
||
"doc": "NewHandler returns an http.Handler for the health endpoint with default configuration.\nRuns all checks concurrently within a 5-second timeout.\nReturns 200 (UP / DEGRADED) or 503 (DOWN).",
|
||
"file": "health/handler.go",
|
||
"line": 28
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "health",
|
||
"kind": "func",
|
||
"name": "NewHandlerWithConfig",
|
||
"signature": "func NewHandlerWithConfig(logger logging.Logger, cfg Config, checks ...observability.Checkable) http.Handler",
|
||
"doc": "NewHandlerWithConfig returns an http.Handler configured by cfg.\nIf cfg.CheckTimeout is zero the default (5 seconds) is applied.",
|
||
"file": "health/handler.go",
|
||
"line": 34
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "health",
|
||
"kind": "const",
|
||
"name": "defaultCheckTimeout",
|
||
"signature": "const defaultCheckTimeout = 5 * time.Second",
|
||
"doc": "",
|
||
"file": "health/config.go",
|
||
"line": 11
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "httputil",
|
||
"kind": "type",
|
||
"name": "HandlerFunc",
|
||
"signature": "type HandlerFunc func(w http.ResponseWriter, r *http.Request) error",
|
||
"doc": "HandlerFunc is an http.Handler that returns an error.\nOn non-nil error the error is mapped to the appropriate HTTP response via [Error].\nUse for manual handlers that need path parameters or custom status codes.",
|
||
"file": "httputil/handler_func.go",
|
||
"line": 10
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "httputil",
|
||
"kind": "method",
|
||
"name": "HandlerFunc.ServeHTTP",
|
||
"signature": "func (h HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request)",
|
||
"doc": "ServeHTTP implements http.Handler.\nErrors are written as standardized JSON without logging — no logger is in\nscope for a bare function type. Use [Handle], [HandleNoBody], or\n[HandleEmpty] for centralized logging, or call [Error] explicitly.",
|
||
"file": "httputil/handler_func.go",
|
||
"line": 16
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "httputil",
|
||
"kind": "func",
|
||
"name": "Error",
|
||
"signature": "func Error(logger logging.Logger, w http.ResponseWriter, r *http.Request, err error)",
|
||
"doc": "Error is the centralized error handler. It logs at the appropriate level and\nwrites a standardized JSON error body.\n\nLog level is derived from the HTTP status:\n - 5xx → Error (unexpected server failure; logz auto-enriches with error_code and context fields)\n - 4xx → Warn (client mistake — not a server failure)\n - 499 → Info (client cancelled the request intentionally)\n\nThe response body always contains code and message; platform_code and context\nfields attached via [xerrors.Err.WithContext] are included when present.",
|
||
"file": "httputil/response.go",
|
||
"line": 35
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "httputil",
|
||
"kind": "func",
|
||
"name": "Handle",
|
||
"signature": "func Handle[Req, Res any](v valid.Validator, logger logging.Logger, fn func(ctx context.Context, req Req) (Res, error)) http.HandlerFunc",
|
||
"doc": "Handle adapts a typed business function to http.HandlerFunc.\n - Decodes the JSON request body into Req.\n - Validates Req using the provided [valid.Validator].\n - Calls fn with the request context and decoded Req.\n - Encodes Res as JSON with HTTP 200 on success.\n - On error: logs via [Error] (level derived from HTTP status) and writes the standardized JSON body.",
|
||
"file": "httputil/handle.go",
|
||
"line": 19
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "httputil",
|
||
"kind": "func",
|
||
"name": "HandleEmpty",
|
||
"signature": "func HandleEmpty[Req any](v valid.Validator, logger logging.Logger, fn func(ctx context.Context, req Req) error) http.HandlerFunc",
|
||
"doc": "HandleEmpty adapts a typed function with a request body but no response body.\nDecodes and validates Req, calls fn, returns 204 No Content on success.\nOn error: logs via [Error] and writes the standardized JSON body.",
|
||
"file": "httputil/handle.go",
|
||
"line": 56
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "httputil",
|
||
"kind": "func",
|
||
"name": "HandleNoBody",
|
||
"signature": "func HandleNoBody[Res any](logger logging.Logger, fn func(ctx context.Context) (Res, error)) http.HandlerFunc",
|
||
"doc": "HandleNoBody adapts a typed function with no request body (GET, HEAD).\nCalls fn with the request context; encodes the result as JSON with HTTP 200.\nOn error: logs via [Error] and writes the standardized JSON body.",
|
||
"file": "httputil/handle.go",
|
||
"line": 42
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "httputil",
|
||
"kind": "func",
|
||
"name": "JSON",
|
||
"signature": "func JSON(w http.ResponseWriter, status int, v any)",
|
||
"doc": "JSON encodes v as JSON and writes it with the given status code.\nSets Content-Type: application/json.",
|
||
"file": "httputil/response.go",
|
||
"line": 14
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "httputil",
|
||
"kind": "func",
|
||
"name": "NoContent",
|
||
"signature": "func NoContent(w http.ResponseWriter)",
|
||
"doc": "NoContent writes a 204 No Content response.",
|
||
"file": "httputil/response.go",
|
||
"line": 21
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "httputil",
|
||
"kind": "func",
|
||
"name": "codeToStatus",
|
||
"signature": "func codeToStatus(code xerrors.Code) int",
|
||
"doc": "",
|
||
"file": "httputil/response.go",
|
||
"line": 88
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "httputil",
|
||
"kind": "func",
|
||
"name": "errorBody",
|
||
"signature": "func errorBody(code, platformCode, message string, fields map[string]any) map[string]any",
|
||
"doc": "",
|
||
"file": "httputil/response.go",
|
||
"line": 74
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "httputil",
|
||
"kind": "func",
|
||
"name": "writeError",
|
||
"signature": "func writeError(w http.ResponseWriter, err error)",
|
||
"doc": "writeError writes the HTTP error response without logging.\nUsed by [HandlerFunc].ServeHTTP where no logger is in scope.",
|
||
"file": "httputil/response.go",
|
||
"line": 65
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "httputil",
|
||
"kind": "var",
|
||
"name": "_",
|
||
"signature": "var _ http.Handler = HandlerFunc(nil)",
|
||
"doc": "",
|
||
"file": "httputil/handler_func.go",
|
||
"line": 5
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "type",
|
||
"name": "InMemoryRateLimiterStore",
|
||
"signature": "type InMemoryRateLimiterStore struct",
|
||
"doc": "InMemoryRateLimiterStore is a per-key token-bucket rate limiter backed by\nan in-memory map. Suitable for single-instance deployments or development.\nFor distributed rate limiting implement [RateLimiterStore] with a Valkey or\nRedis backend and pass it to [IPRateLimit] or [UserRateLimit] instead.\n\nStale entries are evicted every 5 minutes by a background goroutine.\n[Allow] always returns a nil error — in-memory never has infrastructure failures.",
|
||
"file": "mw/in_memory_rate_limiter_store.go",
|
||
"line": 27,
|
||
"fields": [
|
||
{
|
||
"name": "mu",
|
||
"type": "sync.Map"
|
||
},
|
||
{
|
||
"name": "rps",
|
||
"type": "rate.Limit"
|
||
},
|
||
{
|
||
"name": "burst",
|
||
"type": "int"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "func",
|
||
"name": "NewInMemoryRateLimiterStore",
|
||
"signature": "func NewInMemoryRateLimiterStore(rps float64, burst int) *InMemoryRateLimiterStore",
|
||
"doc": "NewInMemoryRateLimiterStore creates a per-key token bucket store.\nrps is the sustained request rate per second per key.\nburst is the maximum instantaneous burst per key.",
|
||
"file": "mw/in_memory_rate_limiter_store.go",
|
||
"line": 36
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "method",
|
||
"name": "InMemoryRateLimiterStore.Allow",
|
||
"signature": "func (s *InMemoryRateLimiterStore) Allow(_ context.Context, key string) (bool, error)",
|
||
"doc": "Allow returns true when the request for key is within the configured rate limit.",
|
||
"file": "mw/in_memory_rate_limiter_store.go",
|
||
"line": 46
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "method",
|
||
"name": "InMemoryRateLimiterStore.cleanupLoop",
|
||
"signature": "func (s *InMemoryRateLimiterStore) cleanupLoop()",
|
||
"doc": "cleanupLoop runs for the lifetime of the process; it stops only when the process exits.\nThis is intentional: InMemoryRateLimiterStore is stateless from a lifecycle perspective.",
|
||
"file": "mw/in_memory_rate_limiter_store.go",
|
||
"line": 59
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "interface",
|
||
"name": "RateLimiterStore",
|
||
"signature": "type RateLimiterStore interface",
|
||
"doc": "RateLimiterStore is the pluggable backend for rate-limiting middleware.\nThe key is determined by the caller (client IP or user ID).\n\nShipped implementations:\n - [InMemoryRateLimiterStore] — per-key token bucket, stdlib only (this package)\n - einherjar/cache-valkey — Valkey-backed store for distributed deployments\n\ncache-valkey satisfies this interface via Go duck typing — it never imports web/mw.",
|
||
"file": "mw/rate_limiter_store.go",
|
||
"line": 13,
|
||
"methods": [
|
||
{
|
||
"name": "Allow",
|
||
"signature": "Allow(ctx context.Context, key string) (bool, error)",
|
||
"doc": "Allow returns true when the request for the given key is within the rate limit.\nA non-nil error means the store is temporarily unavailable; middleware fails open."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "type",
|
||
"name": "StatusRecorder",
|
||
"signature": "type StatusRecorder struct",
|
||
"doc": "StatusRecorder wraps http.ResponseWriter to capture the written status code.\nUsed by [RequestLogger] to log the response status after the handler returns.",
|
||
"file": "mw/status_recorder.go",
|
||
"line": 7,
|
||
"fields": [
|
||
{
|
||
"type": "http.ResponseWriter",
|
||
"embedded": true
|
||
},
|
||
{
|
||
"name": "Status",
|
||
"type": "int"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "method",
|
||
"name": "StatusRecorder.WriteHeader",
|
||
"signature": "func (r *StatusRecorder) WriteHeader(code int)",
|
||
"doc": "WriteHeader captures the status code and delegates to the underlying writer.",
|
||
"file": "mw/status_recorder.go",
|
||
"line": 13
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "type",
|
||
"name": "limiterEntry",
|
||
"signature": "type limiterEntry struct",
|
||
"doc": "",
|
||
"file": "mw/in_memory_rate_limiter_store.go",
|
||
"line": 15,
|
||
"fields": [
|
||
{
|
||
"name": "limiter",
|
||
"type": "*rate.Limiter"
|
||
},
|
||
{
|
||
"name": "lastSeen",
|
||
"type": "time.Time"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "func",
|
||
"name": "CORS",
|
||
"signature": "func CORS(origins []string) func(http.Handler) http.Handler",
|
||
"doc": "CORS sets cross-origin resource sharing headers for the provided origins\n(exact match; an empty slice is a no-op). Returns 204 No Content for OPTIONS\npreflight requests.\n\nIt panics on \"*\": a wildcard matches no real Origin here, so passing it would\nsilently disable CORS. For allow-all use [CORSAllowAll] (development only). The\nrecommended wiring gates CORS by environment:\n\n\tvar corsMW func(http.Handler) http.Handler\n\tif strings.EqualFold(cfg.AppEnv, \"local\") {\n\t corsMW = mw.CORSAllowAll() // dev: any origin\n\t} else {\n\t corsMW = mw.CORS(cfg.CORSOrigins) // prod: explicit origins\n\t}",
|
||
"file": "mw/cors.go",
|
||
"line": 24
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "func",
|
||
"name": "CORSAllowAll",
|
||
"signature": "func CORSAllowAll() func(http.Handler) http.Handler",
|
||
"doc": "CORSAllowAll allows any origin by reflecting the request Origin (it does not set\nAccess-Control-Allow-Credentials). Development only — never in production.\n\nUse it for the local branch of the env-gated CORS convention; use [CORS] with\nexplicit origins everywhere else. Because [CORS] panics on \"*\", CORSAllowAll — not\na \"*\" in the origins list — is the way to allow all.",
|
||
"file": "mw/cors.go",
|
||
"line": 65
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "func",
|
||
"name": "IPRateLimit",
|
||
"signature": "func IPRateLimit(store RateLimiterStore, logger logging.Logger) func(http.Handler) http.Handler",
|
||
"doc": "IPRateLimit returns middleware that rate-limits requests by client IP address.\nThe IP is extracted from X-Forwarded-For (first value) or RemoteAddr.\nWhen the store returns an error the middleware fails open: the error is logged\nand the request is allowed through.",
|
||
"file": "mw/rate_limit.go",
|
||
"line": 16
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "func",
|
||
"name": "Recover",
|
||
"signature": "func Recover(logger logging.Logger) func(http.Handler) http.Handler",
|
||
"doc": "Recover catches panics in downstream handlers, writes a 500 response, and\nlogs the recovered value with a stack trace. Place it as the outermost middleware.",
|
||
"file": "mw/recover.go",
|
||
"line": 12
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "func",
|
||
"name": "RequestID",
|
||
"signature": "func RequestID(generator func() string) func(http.Handler) http.Handler",
|
||
"doc": "RequestID injects a unique request ID into the context (via [logz.WithRequestID])\nand sets the X-Request-ID response header.\ngenerator is called once per request — pass uuid.NewString or a custom function.",
|
||
"file": "mw/requestid.go",
|
||
"line": 12
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "func",
|
||
"name": "RequestLogger",
|
||
"signature": "func RequestLogger(logger logging.Logger) func(http.Handler) http.Handler",
|
||
"doc": "RequestLogger logs each request after the handler returns, including method,\npath, status code, and latency. Uses [StatusRecorder] to capture the status.\nPlace after [RequestID] so the request ID is available in the log record.",
|
||
"file": "mw/logger.go",
|
||
"line": 13
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "func",
|
||
"name": "UserRateLimit",
|
||
"signature": "func UserRateLimit(store RateLimiterStore, logger logging.Logger) func(http.Handler) http.Handler",
|
||
"doc": "UserRateLimit returns middleware that rate-limits by authenticated user ID.\nFalls back to client IP when no [security.Identity] is present in the context.\nWhen the store returns an error the middleware fails open.",
|
||
"file": "mw/rate_limit.go",
|
||
"line": 35
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "func",
|
||
"name": "clientIP",
|
||
"signature": "func clientIP(r *http.Request) string",
|
||
"doc": "",
|
||
"file": "mw/rate_limit.go",
|
||
"line": 54
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "func",
|
||
"name": "rateLimitExceeded",
|
||
"signature": "func rateLimitExceeded(w http.ResponseWriter)",
|
||
"doc": "",
|
||
"file": "mw/rate_limit.go",
|
||
"line": 66
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "const",
|
||
"name": "allowedMethods",
|
||
"signature": "const (\n allowedMethods = \"GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS\"\n allowedHeaders = \"Content-Type, Authorization, X-Request-ID\"\n)",
|
||
"doc": "",
|
||
"file": "mw/cors.go",
|
||
"line": 5
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "const",
|
||
"name": "allowedHeaders",
|
||
"signature": "const (\n allowedMethods = \"GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS\"\n allowedHeaders = \"Content-Type, Authorization, X-Request-ID\"\n)",
|
||
"doc": "",
|
||
"file": "mw/cors.go",
|
||
"line": 5
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "mw",
|
||
"kind": "const",
|
||
"name": "inMemoryCleanupInterval",
|
||
"signature": "const inMemoryCleanupInterval = 5 * time.Minute",
|
||
"doc": "",
|
||
"file": "mw/in_memory_rate_limiter_store.go",
|
||
"line": 13
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "server",
|
||
"kind": "type",
|
||
"name": "Config",
|
||
"signature": "type Config struct",
|
||
"doc": "Config holds HTTP server configuration.\nAll fields carry caarlos0/env struct tags — applications supply the loader.",
|
||
"file": "server/config.go",
|
||
"line": 7,
|
||
"fields": [
|
||
{
|
||
"name": "Host",
|
||
"type": "string",
|
||
"tag": "env:\"EINHERJAR_SERVER_HOST\" envDefault:\"0.0.0.0\""
|
||
},
|
||
{
|
||
"name": "Port",
|
||
"type": "int",
|
||
"tag": "env:\"EINHERJAR_SERVER_PORT\" envDefault:\"8080\""
|
||
},
|
||
{
|
||
"name": "ReadTimeout",
|
||
"type": "time.Duration",
|
||
"tag": "env:\"EINHERJAR_SERVER_READ_TIMEOUT\" envDefault:\"5s\""
|
||
},
|
||
{
|
||
"name": "WriteTimeout",
|
||
"type": "time.Duration",
|
||
"tag": "env:\"EINHERJAR_SERVER_WRITE_TIMEOUT\" envDefault:\"10s\""
|
||
},
|
||
{
|
||
"name": "IdleTimeout",
|
||
"type": "time.Duration",
|
||
"tag": "env:\"EINHERJAR_SERVER_IDLE_TIMEOUT\" envDefault:\"120s\""
|
||
},
|
||
{
|
||
"name": "ShutdownTimeout",
|
||
"type": "time.Duration",
|
||
"tag": "env:\"EINHERJAR_SERVER_SHUTDOWN_TIMEOUT\" envDefault:\"10s\""
|
||
},
|
||
{
|
||
"name": "CORSOrigins",
|
||
"type": "[]string",
|
||
"tag": "env:\"EINHERJAR_SERVER_CORS_ORIGINS\" envSeparator:\",\"",
|
||
"doc": "CORSOrigins is the allowed cross-origin list (comma-separated in the env var).\nweb.New applies mw.CORS with it automatically; callers of server.New pass it to\nmw.CORS themselves. \"*\" is rejected by mw.CORS — use mw.CORSAllowAll for allow-all\n(development only)."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "server",
|
||
"kind": "type",
|
||
"name": "Option",
|
||
"signature": "type Option func(*serverOpts)",
|
||
"doc": "Option configures a [Server] at construction time.",
|
||
"file": "server/option.go",
|
||
"line": 6
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "server",
|
||
"kind": "func",
|
||
"name": "WithMiddleware",
|
||
"signature": "func WithMiddleware(middleware ...func(http.Handler) http.Handler) Option",
|
||
"doc": "WithMiddleware registers one or more middleware functions applied to the\nroot chi router during [lifecycle.Component.OnInit].\nMiddleware is applied in registration order (outermost first).",
|
||
"file": "server/option.go",
|
||
"line": 11
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "server",
|
||
"kind": "interface",
|
||
"name": "Server",
|
||
"signature": "type Server interface",
|
||
"doc": "Server is a lifecycle-managed HTTP server that exposes a chi router.\nEmbed chi.Router gives callers the full routing API: Get, Post, Route, Mount, Use, etc.",
|
||
"file": "server/server.go",
|
||
"line": 33,
|
||
"methods": [
|
||
{
|
||
"signature": "lifecycle.Component"
|
||
},
|
||
{
|
||
"signature": "observability.Identifiable"
|
||
},
|
||
{
|
||
"signature": "chi.Router"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "server",
|
||
"kind": "func",
|
||
"name": "New",
|
||
"signature": "func New(logger logging.Logger, cfg Config, opts ...Option) Server",
|
||
"doc": "New creates a [Server]. No middleware is applied by default; use [WithMiddleware]\nto compose the middleware stack before passing it to [launcher.New].",
|
||
"file": "server/server_impl.go",
|
||
"line": 30
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "server",
|
||
"kind": "type",
|
||
"name": "impl",
|
||
"signature": "type impl struct",
|
||
"doc": "",
|
||
"file": "server/server_impl.go",
|
||
"line": 20,
|
||
"fields": [
|
||
{
|
||
"type": "chi.Router",
|
||
"embedded": true
|
||
},
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
},
|
||
{
|
||
"name": "cfg",
|
||
"type": "Config"
|
||
},
|
||
{
|
||
"name": "opts",
|
||
"type": "serverOpts"
|
||
},
|
||
{
|
||
"name": "srv",
|
||
"type": "*http.Server"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "server",
|
||
"kind": "method",
|
||
"name": "impl.ModulePath",
|
||
"signature": "func (s *impl) ModulePath() string",
|
||
"doc": "",
|
||
"file": "server/identifiable.go",
|
||
"line": 7
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "server",
|
||
"kind": "method",
|
||
"name": "impl.ModuleVersion",
|
||
"signature": "func (s *impl) ModuleVersion() string",
|
||
"doc": "",
|
||
"file": "server/identifiable.go",
|
||
"line": 9
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "server",
|
||
"kind": "method",
|
||
"name": "impl.OnInit",
|
||
"signature": "func (s *impl) OnInit() error",
|
||
"doc": "OnInit applies registered middleware to the router.",
|
||
"file": "server/server_impl.go",
|
||
"line": 44
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "server",
|
||
"kind": "method",
|
||
"name": "impl.OnStart",
|
||
"signature": "func (s *impl) OnStart() error",
|
||
"doc": "OnStart binds the TCP listener synchronously so that a port conflict surfaces\nimmediately — the launcher can trigger a clean shutdown instead of running\nsilently without an HTTP server. Requests are served in a background goroutine.",
|
||
"file": "server/server_impl.go",
|
||
"line": 54
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "server",
|
||
"kind": "method",
|
||
"name": "impl.OnStop",
|
||
"signature": "func (s *impl) OnStop() error",
|
||
"doc": "OnStop performs a graceful shutdown, waiting up to ShutdownTimeout for\nin-flight requests to complete. No-op if OnStart was never called.",
|
||
"file": "server/server_impl.go",
|
||
"line": 88
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "server",
|
||
"kind": "type",
|
||
"name": "serverOpts",
|
||
"signature": "type serverOpts struct",
|
||
"doc": "",
|
||
"file": "server/option.go",
|
||
"line": 17,
|
||
"fields": [
|
||
{
|
||
"name": "middleware",
|
||
"type": "[]func(http.Handler) http.Handler"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "server",
|
||
"kind": "const",
|
||
"name": "defaultShutdownTimeout",
|
||
"signature": "const defaultShutdownTimeout = 10 * time.Second",
|
||
"doc": "",
|
||
"file": "server/config.go",
|
||
"line": 22
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "server",
|
||
"kind": "const",
|
||
"name": "modulePath",
|
||
"signature": "const modulePath = \"code.nochebuena.dev/einherjar/web\"",
|
||
"doc": "",
|
||
"file": "server/identifiable.go",
|
||
"line": 5
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "server",
|
||
"kind": "var",
|
||
"name": "_",
|
||
"signature": "var _ observability.Identifiable = (*impl)(nil)",
|
||
"doc": "",
|
||
"file": "server/server_impl.go",
|
||
"line": 18
|
||
}
|
||
],
|
||
"adrs": null,
|
||
"examples": [
|
||
{
|
||
"module": "web",
|
||
"subPackage": "",
|
||
"title": "Tier 1 — Happy path (`web.New`)",
|
||
"code": "import (\n \"code.nochebuena.dev/einherjar/core/logz\"\n \"code.nochebuena.dev/einherjar/web\"\n \"code.nochebuena.dev/einherjar/web/health\"\n)\n\nlogger := logz.New(logz.Config{JSON: true, StaticArgs: []any{\"service\", \"api\"}})\n\nsrv := web.New(logger)\n// Pre-wired stack: Recover → RequestID (UUID v7/v4) → RequestLogger → [CORS]\n\nsrv.Get(\"/health\", health.NewHandler(logger, db, cache).ServeHTTP)\n\nlc := launcher.New(logger)\nlc.Append(srv)\nlc.BeforeStart(func() error {\n srv.Mount(\"/v1\", myRouter)\n return nil\n})\nlc.Run()",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "",
|
||
"title": "Tier 1 — Happy path (`web.New`)",
|
||
"code": "srv := web.New(logger, web.Config{\n Server: server.Config{\n Port: 9090,\n CORSOrigins: []string{\"https://example.com\"},\n },\n})",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "",
|
||
"title": "Tier 2 — Full control (`server.New`)",
|
||
"code": "import (\n \"code.nochebuena.dev/einherjar/web/mw\"\n \"code.nochebuena.dev/einherjar/web/server\"\n)\n\nsrv := server.New(logger, server.Config{Port: 9090},\n server.WithMiddleware(\n mw.Recover(logger),\n mw.RequestID(myIDGenerator),\n mw.CORS([]string{\"https://example.com\"}),\n mw.RequestLogger(logger),\n myOwnMiddleware,\n ),\n)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "",
|
||
"title": "Middleware",
|
||
"code": "import \"code.nochebuena.dev/einherjar/web/mw\"\n\n// Rate limiting — in-memory token bucket (swap for distributed store at scale)\nlimiter := mw.NewInMemoryRateLimiterStore(100, 20) // rps=100, burst=20\nsrv.Use(mw.IPRateLimit(limiter, logger))\nsrv.Use(mw.UserRateLimit(limiter, logger))\n\n// Scale: swap store without changing middleware\n// valkeyLimiter := valkeymw.NewRateLimiterStore(valkey, 100, 20) // implements mw.RateLimiterStore\n// srv.Use(mw.IPRateLimit(valkeyLimiter, logger))",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "",
|
||
"title": "Generic handlers",
|
||
"code": "import (\n \"code.nochebuena.dev/einherjar/core/valid\"\n \"code.nochebuena.dev/einherjar/web/httputil\"\n)\n\ntype CreateUserReq struct {\n Email string `json:\"email\" validate:\"required,email\"`\n Name string `json:\"name\" validate:\"required\"`\n}\ntype CreateUserRes struct {\n ID string `json:\"id\"`\n}\n\nv := valid.New()\n\n// POST /users — decode body → validate → call service → encode response\nsrv.Post(\"/users\", httputil.Handle(v, func(ctx context.Context, req CreateUserReq) (CreateUserRes, error) {\n id, err := userService.Create(ctx, req.Email, req.Name)\n if err != nil {\n return CreateUserRes{}, err\n }\n return CreateUserRes{ID: id}, nil\n}))\n\n// GET /users/{id} — no request body\nsrv.Get(\"/users/{id}\", httputil.HandleNoBody(func(ctx context.Context) (CreateUserRes, error) {\n // ...\n}))\n\n// DELETE /users/{id} — no response body\nsrv.Delete(\"/users/{id}\", httputil.HandleEmpty(v, func(ctx context.Context, req DeleteReq) error {\n return userService.Delete(ctx, req.ID)\n}))",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "",
|
||
"title": "Health endpoint",
|
||
"code": "import \"code.nochebuena.dev/einherjar/web/health\"\n\n// db and cache implement observability.Checkable\nsrv.Get(\"/health\", health.NewHandler(logger, db, cache).ServeHTTP)\n\n// Response shape:\n// {\"status\":\"UP\",\"components\":{\"db\":{\"status\":\"UP\",\"latency\":\"1.2ms\"}}}\n// {\"status\":\"DEGRADED\",\"components\":{\"cache\":{\"status\":\"DEGRADED\",\"latency\":\"50ms\",\"error\":\"timeout\"}}}\n// {\"status\":\"DOWN\",\"components\":{\"db\":{\"status\":\"DOWN\",\"latency\":\"5s\",\"error\":\"connection refused\"}}}",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "",
|
||
"title": "Dependency Graph",
|
||
"code": "contracts (zero dependencies)\n ↑\n core (contracts)\n ↑\n web (contracts, core, chi/v5, uuid, x/time)\n ↑\n your app",
|
||
"language": ""
|
||
},
|
||
{
|
||
"module": "web",
|
||
"subPackage": "",
|
||
"title": "Verification",
|
||
"code": "cd web/\ngo build ./... # must compile clean\ngo vet ./... # no warnings\ngo test ./... # structural + behavioural compliance passes\ngofmt -l . # no output",
|
||
"language": "bash"
|
||
}
|
||
],
|
||
"compliance": {
|
||
"interfaceAsserts": [
|
||
{
|
||
"module": "web",
|
||
"interface": "mw.RateLimiterStore",
|
||
"impl": "(*mw.InMemoryRateLimiterStore)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 33
|
||
},
|
||
{
|
||
"module": "web",
|
||
"interface": "observability.Checkable",
|
||
"impl": "(*mockCheckable)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 125
|
||
},
|
||
{
|
||
"module": "web",
|
||
"interface": "mw.RateLimiterStore",
|
||
"impl": "(*errorStore)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 437
|
||
},
|
||
{
|
||
"module": "web",
|
||
"interface": "io.Writer",
|
||
"impl": "io.Discard",
|
||
"file": "compliance_test.go",
|
||
"line": 440
|
||
}
|
||
],
|
||
"tests": [
|
||
{
|
||
"module": "web",
|
||
"name": "TestAtMostOneExportedTypePerFile",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 43
|
||
},
|
||
{
|
||
"module": "web",
|
||
"name": "TestServerConfigDefaults",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 95
|
||
},
|
||
{
|
||
"module": "web",
|
||
"name": "TestServerNew",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 105
|
||
},
|
||
{
|
||
"module": "web",
|
||
"name": "TestHealthConfigDefaultTimeout",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 127
|
||
},
|
||
{
|
||
"module": "web",
|
||
"name": "TestHealthHandlerAllUp",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 136
|
||
},
|
||
{
|
||
"module": "web",
|
||
"name": "TestHealthHandlerCriticalDown",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 156
|
||
},
|
||
{
|
||
"module": "web",
|
||
"name": "TestHealthHandlerDegradedDown",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 174
|
||
},
|
||
{
|
||
"module": "web",
|
||
"name": "TestHTTPUtilErrorMapping",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 194
|
||
},
|
||
{
|
||
"module": "web",
|
||
"name": "TestHTTPUtilHandle",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 227
|
||
},
|
||
{
|
||
"module": "web",
|
||
"name": "TestHTTPUtilHandleValidationError",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 256
|
||
},
|
||
{
|
||
"module": "web",
|
||
"name": "TestHTTPUtilHandleNoBody",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 276
|
||
},
|
||
{
|
||
"module": "web",
|
||
"name": "TestHTTPUtilHandleEmpty",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 293
|
||
},
|
||
{
|
||
"module": "web",
|
||
"name": "TestMWStatusRecorder",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 313
|
||
},
|
||
{
|
||
"module": "web",
|
||
"name": "TestInMemoryRateLimiterStore",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 322
|
||
},
|
||
{
|
||
"module": "web",
|
||
"name": "TestIPRateLimit",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 344
|
||
},
|
||
{
|
||
"module": "web",
|
||
"name": "TestIPRateLimitFailOpen",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 369
|
||
},
|
||
{
|
||
"module": "web",
|
||
"name": "TestUserRateLimit",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 384
|
||
},
|
||
{
|
||
"module": "web",
|
||
"name": "TestWebNew",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 420
|
||
}
|
||
]
|
||
},
|
||
"readme": "# einherjar/web\n\n[](https://code.nochebuena.dev/einherjar/web)\n[](LICENSE)\n[](https://go.dev)\n\n\u003e The gate is not a barrier. It is the point where the outside world meets order.\n\n`code.nochebuena.dev/einherjar/web` is the HTTP layer of the Einherjar framework.\nIt sits above `core` in the dependency graph and provides everything a service needs\nto receive, process, and respond to HTTP requests: a lifecycle-aware server, a\ncomposable middleware stack, type-safe generic handlers, and a concurrent health\nendpoint.\n\n---\n\n## Sub-packages\n\n| Package | Import path | Purpose |\n|---|---|---|\n| `server` | `.../web/server` | Lifecycle-aware HTTP server (chi router + `lifecycle.Component`) |\n| `mw` | `.../web/mw` | Middleware: Recover, RequestID, RequestLogger, CORS, rate limiting |\n| `httputil` | `.../web/httputil` | Generic handler adapters: decode → validate → call → encode |\n| `health` | `.../web/health` | Concurrent health-check endpoint consuming `observability.Checkable` |\n\nAll four are in one module because they compose together and ship in every\nEinherjar HTTP service.\n\n---\n\n## Usage\n\n### Tier 1 — Happy path (`web.New`)\n\nZero config, safe defaults, all env vars respected:\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/core/logz\"\n \"code.nochebuena.dev/einherjar/web\"\n \"code.nochebuena.dev/einherjar/web/health\"\n)\n\nlogger := logz.New(logz.Config{JSON: true, StaticArgs: []any{\"service\", \"api\"}})\n\nsrv := web.New(logger)\n// Pre-wired stack: Recover → RequestID (UUID v7/v4) → RequestLogger → [CORS]\n\nsrv.Get(\"/health\", health.NewHandler(logger, db, cache).ServeHTTP)\n\nlc := launcher.New(logger)\nlc.Append(srv)\nlc.BeforeStart(func() error {\n srv.Mount(\"/v1\", myRouter)\n return nil\n})\nlc.Run()\n```\n\nWith origins set in code (CORS auto-applied). `Server.CORSOrigins` is the single\nsource of truth — normally it loads from `EINHERJAR_SERVER_CORS_ORIGINS`, but you\ncan set it directly to override without the env var:\n\n```go\nsrv := web.New(logger, web.Config{\n Server: server.Config{\n Port: 9090,\n CORSOrigins: []string{\"https://example.com\"},\n },\n})\n```\n\nEnvironment variables for `web.New`:\n\n| Variable | Default | Effect |\n|---|---|---|\n| `EINHERJAR_SERVER_HOST` | `0.0.0.0` | Bind address |\n| `EINHERJAR_SERVER_PORT` | `8080` | Listen port |\n| `EINHERJAR_SERVER_READ_TIMEOUT` | `5s` | HTTP read timeout |\n| `EINHERJAR_SERVER_WRITE_TIMEOUT` | `10s` | HTTP write timeout |\n| `EINHERJAR_SERVER_IDLE_TIMEOUT` | `120s` | Keep-alive idle timeout |\n| `EINHERJAR_SERVER_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown budget |\n| `EINHERJAR_SERVER_CORS_ORIGINS` | _(empty — CORS off)_ | Comma-separated allowed origins (`*` is rejected — use `mw.CORSAllowAll()` in code for allow-all) |\n\n### Tier 2 — Full control (`server.New`)\n\nExplicit middleware composition:\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/web/mw\"\n \"code.nochebuena.dev/einherjar/web/server\"\n)\n\nsrv := server.New(logger, server.Config{Port: 9090},\n server.WithMiddleware(\n mw.Recover(logger),\n mw.RequestID(myIDGenerator),\n mw.CORS([]string{\"https://example.com\"}),\n mw.RequestLogger(logger),\n myOwnMiddleware,\n ),\n)\n```\n\nBoth tiers share the same `server.Config`, env variables, and `lifecycle.Component`\ncontract. The only difference is how much wiring is automated.\n\n---\n\n### Middleware\n\n```go\nimport \"code.nochebuena.dev/einherjar/web/mw\"\n\n// Rate limiting — in-memory token bucket (swap for distributed store at scale)\nlimiter := mw.NewInMemoryRateLimiterStore(100, 20) // rps=100, burst=20\nsrv.Use(mw.IPRateLimit(limiter, logger))\nsrv.Use(mw.UserRateLimit(limiter, logger))\n\n// Scale: swap store without changing middleware\n// valkeyLimiter := valkeymw.NewRateLimiterStore(valkey, 100, 20) // implements mw.RateLimiterStore\n// srv.Use(mw.IPRateLimit(valkeyLimiter, logger))\n```\n\n`IPRateLimit` uses `X-Forwarded-For` → `RemoteAddr` as the rate-limit key.\n`UserRateLimit` uses the authenticated user ID from `security.Identity`; falls back\nto client IP when no identity is present in context.\n\nBoth middlewares fail **open** on store error — the request is allowed, the error\nis logged. This keeps the service available when the rate-limit store is degraded.\n\n---\n\n### Generic handlers\n\n```go\nimport (\n \"code.nochebuena.dev/einherjar/core/valid\"\n \"code.nochebuena.dev/einherjar/web/httputil\"\n)\n\ntype CreateUserReq struct {\n Email string `json:\"email\" validate:\"required,email\"`\n Name string `json:\"name\" validate:\"required\"`\n}\ntype CreateUserRes struct {\n ID string `json:\"id\"`\n}\n\nv := valid.New()\n\n// POST /users — decode body → validate → call service → encode response\nsrv.Post(\"/users\", httputil.Handle(v, func(ctx context.Context, req CreateUserReq) (CreateUserRes, error) {\n id, err := userService.Create(ctx, req.Email, req.Name)\n if err != nil {\n return CreateUserRes{}, err\n }\n return CreateUserRes{ID: id}, nil\n}))\n\n// GET /users/{id} — no request body\nsrv.Get(\"/users/{id}\", httputil.HandleNoBody(func(ctx context.Context) (CreateUserRes, error) {\n // ...\n}))\n\n// DELETE /users/{id} — no response body\nsrv.Delete(\"/users/{id}\", httputil.HandleEmpty(v, func(ctx context.Context, req DeleteReq) error {\n return userService.Delete(ctx, req.ID)\n}))\n```\n\nValidation failures return 400 with a structured JSON error. All `*xerrors.Err`\nvalues are mapped to their canonical HTTP status codes (full 16-code table below).\n\n---\n\n### Health endpoint\n\n```go\nimport \"code.nochebuena.dev/einherjar/web/health\"\n\n// db and cache implement observability.Checkable\nsrv.Get(\"/health\", health.NewHandler(logger, db, cache).ServeHTTP)\n\n// Response shape:\n// {\"status\":\"UP\",\"components\":{\"db\":{\"status\":\"UP\",\"latency\":\"1.2ms\"}}}\n// {\"status\":\"DEGRADED\",\"components\":{\"cache\":{\"status\":\"DEGRADED\",\"latency\":\"50ms\",\"error\":\"timeout\"}}}\n// {\"status\":\"DOWN\",\"components\":{\"db\":{\"status\":\"DOWN\",\"latency\":\"5s\",\"error\":\"connection refused\"}}}\n```\n\nAll checks run concurrently within a configurable timeout (default 5s).\n`DOWN` (critical priority failure) → HTTP 503. `DEGRADED` (degraded priority failure) → HTTP 200.\n\nEnvironment variables:\n\n| Variable | Default | Effect |\n|---|---|---|\n| `EINHERJAR_HEALTH_CHECK_TIMEOUT` | `5s` | Maximum time to wait for all checks |\n\n---\n\n## HTTP Status Code Mapping\n\n`httputil.Error` maps `*xerrors.Err` codes to HTTP status:\n\n| Code | HTTP |\n|---|---|\n| `ErrInvalidInput`, `ErrOutOfRange` | 400 |\n| `ErrUnauthorized` | 401 |\n| `ErrPermissionDenied` | 403 |\n| `ErrNotFound` | 404 |\n| `ErrAlreadyExists`, `ErrAborted` | 409 |\n| `ErrGone` | 410 |\n| `ErrPreconditionFailed` | 412 |\n| `ErrRateLimited` | 429 |\n| `ErrCancelled` | 499 |\n| `ErrInternal`, `ErrDataLoss` | 500 |\n| `ErrNotImplemented` | 501 |\n| `ErrUnavailable` | 503 |\n| `ErrDeadlineExceeded` | 504 |\n\n---\n\n## Dependency Graph\n\n```\ncontracts (zero dependencies)\n ↑\n core (contracts)\n ↑\n web (contracts, core, chi/v5, uuid, x/time)\n ↑\n your app\n```\n\n`db-*`, `cache-*`, and `storage-*` starters never import `web` — they only need\n`contracts` and `core`. Repositories do not know HTTP exists.\n\n---\n\n## Verification\n\n```bash\ncd web/\ngo build ./... # must compile clean\ngo vet ./... # no warnings\ngo test ./... # structural + behavioural compliance passes\ngofmt -l . # no output\n```\n\n---\n\n\u003e *The gate does not decide who passes.*\n\u003e *It decides that passing has consequences.*\n",
|
||
"changelog": "# Changelog — einherjar/web\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor release carrying a **breaking API change** to CORS configuration. The framework is\nprivate with controlled consumers, so this ships in the 1.x line with a loud compile break\ninstead of a v2 module-path (`/v2`) migration.\n\n### Removed\n\n- **⚠️ BREAKING: `web.Config.AllowedOrigins` removed.** CORS origins now have a single\n home: `server.Config.CORSOrigins` (env `EINHERJAR_SERVER_CORS_ORIGINS`). The field was\n env-backed through v1.1.x and a code-only override in v1.2.0 — reading it after the env\n tag moved silently served *no* CORS. Removing it turns that runtime trap into a compile\n error.\n\n **Migration:** replace `cfg.Web.AllowedOrigins` with `cfg.Server.CORSOrigins`, and\n `web.Config{AllowedOrigins: o}` with `web.Config{Server: server.Config{CORSOrigins: o}}`\n — or just let `web.New` read `EINHERJAR_SERVER_CORS_ORIGINS`. The MCP flags any leftover\n reference (`validate_snippet` rule `web.allowedorigins-removed`).\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — CORS configuration moved to its rightful struct; `web.New` made safe-by-default.\n\n### Changed\n\n- **`CORSOrigins` now lives on `server.Config`** (env var `EINHERJAR_SERVER_CORS_ORIGINS`), the\n struct its name advertises — it previously loaded into `web.Config`. `web.Config.AllowedOrigins`\n remains as a code-only override (no env tag). Wiring via `web.New` or the env var is unaffected.\n- Bumped `contracts`, `core` to v1.2.0.\n\n### Added\n\n- `web.New` logs a warning when no CORS origins are configured, instead of silently disabling CORS.\n- Package docs (`web`, `web/server`) document when to use `web.New` vs `server.New`, with compiling\n examples and the env-gated allow-all CORS convention.\n\n## [1.1.3] — 2026-08-08\n\nPatch — CORS documentation discoverability.\n\n### Fixed\n\n- `mw.CORS` and `CORSAllowAll` doc comments now document the `\"*\"` rejection (panic) and the\n env-gated CORS convention (`local -\u003e CORSAllowAll`, else `mw.CORS(origins)`), so `search_symbols`\n surfaces it — previously the convention lived only in code comments and the wire example.\n\n### Changed\n\n- Bumped `contracts`, `core` to v1.1.3.\n\n## [1.1.2] — 2026-08-08\n\nPatch — CORS wildcard hardening plus documentation fixes.\n\n### Changed\n\n- **`mw.CORS` now rejects `\"*\"` (panics at construction)** instead of silently no-op'ing it.\n `\"*\"` matched nothing (exact-match only), so a service passing it ran with CORS effectively\n off — a silent trap. Fail loud at boot; use `mw.CORSAllowAll()` (development) or list explicit origins.\n- Bumped `contracts`, `core` to v1.1.2.\n\n### Fixed\n\n- README Go examples now compile: `mw.Recover(logger)`, `health.NewHandler(...).ServeHTTP`, and the\n `mw.CORS` example no longer passes `\"*\"`. Corrected the `CORSAllowAll` description.\n\n## [1.1.1] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped `contracts` and `core` to v1.1.1 (framework version alignment). No code or API changes.\n\n## [1.1.0] — 2026-08-07\n\nCoordinated framework release. Documentation fixes plus the framework version bump\n(which finally makes the previously-drafted `contracts` v1.1.0 pin real).\n\n### Fixed\n\n- **Package doc examples didn't compile.** Verified by compiling the example patterns\n against the real API:\n - `mw.Recover()` -\u003e `mw.Recover(logger)` — the recover middleware takes a `logging.Logger`\n (`server`, `mw` package docs and `server.go`).\n - `health.NewHandler(logger, …)` -\u003e `health.NewHandler(logger, …).ServeHTTP` — the handler\n returns `http.Handler`, but chi's `Get` takes `http.HandlerFunc`; same for\n `NewHandlerWithConfig` (`server`, `web`, `health` package docs).\n\n### Changed\n\n- Bumped `contracts` and `core` to v1.1.0 (framework version alignment).\n\n---\n\n## [1.0.0] — 2026-05-28\n\n### Added\n\n#### `server`\n\n- `Server` interface — embeds `lifecycle.Component` (from `contracts/lifecycle`) and\n `chi.Router` (from `go-chi/chi/v5`); any type that satisfies both is directly\n compatible\n- `Config` struct — `Host`, `Port`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`,\n `ShutdownTimeout`; all fields carry `env:\"EINHERJAR_SERVER_*\"` and `envDefault`\n tags (`caarlos0/env` syntax)\n- `New(logger logging.Logger, cfg Config, opts ...Option) Server` — constructs the\n unexported `impl` struct; embeds `chi.NewRouter()`\n- `Option` type + `WithMiddleware(mw ...func(http.Handler) http.Handler) Option` —\n variadic option for middleware composition\n- `impl.OnInit()` — applies registered middleware via `chi.Use`\n- `impl.OnStart()` — binds TCP listener synchronously (`net.Listen`), starts\n `http.Server.Serve` in a goroutine; port binding failure returns immediately\n- `impl.OnStop(ctx)` — graceful `http.Server.Shutdown(ctx)` with `ShutdownTimeout`\n (fallback: `defaultShutdownTimeout = 10s`)\n- `var _ Server = (*impl)(nil)` — compile-time assertion\n\n#### `mw`\n\n- `StatusRecorder` struct — wraps `http.ResponseWriter`, captures written status code\n- `Recover() func(http.Handler) http.Handler` — catches panics, writes 500, logs\n stack trace via `runtime/debug.Stack()`\n- `RequestID(generator func() string) func(http.Handler) http.Handler` — injects a\n request ID via `logz.WithRequestID`; reads existing `X-Request-ID` header if present\n- `RequestLogger(logger logging.Logger) func(http.Handler) http.Handler` — structured\n request logging: method, path, status, latency; uses `StatusRecorder` to capture code\n- `CORS(origins []string) func(http.Handler) http.Handler` — sets\n `Access-Control-Allow-Origin` for listed origins; supports preflight (`OPTIONS`)\n- `CORSAllowAll() func(http.Handler) http.Handler` — allows any origin by reflecting the request `Origin` (no `Access-Control-Allow-Credentials`); development only\n- `RateLimiterStore` interface — `Allow(ctx context.Context, key string) (bool, error)`;\n pluggable backend; `error` return allows infrastructure failures to surface; fail-open\n contract: non-nil error allows the request\n- `InMemoryRateLimiterStore` struct — per-key token bucket via `golang.org/x/time/rate`;\n `sync.Map` for concurrent access; background goroutine evicts idle entries after 5\n minutes via `time.Ticker`; `Allow` always returns `(bool, nil)`\n- `NewInMemoryRateLimiterStore(rps float64, burst int) *InMemoryRateLimiterStore`\n- `IPRateLimit(store RateLimiterStore, logger logging.Logger) func(http.Handler) http.Handler`\n — limits by client IP (`X-Forwarded-For` → `RemoteAddr` fallback); returns 429 JSON\n on exceeded limit; fails open on store error\n- `UserRateLimit(store RateLimiterStore, logger logging.Logger) func(http.Handler) http.Handler`\n — limits by authenticated user ID from `security.FromContext`; falls back to client IP\n when no identity present; same 429 + fail-open behaviour\n\n#### `httputil`\n\n- `HandlerFunc` type — `func(w http.ResponseWriter, r *http.Request) error`; implements\n `http.Handler` via `ServeHTTP`\n- `Handle[Req, Res any](v valid.Validator, fn func(ctx context.Context, req Req) (Res, error)) http.HandlerFunc`\n — decodes JSON body, validates struct, calls `fn`, encodes response; 400 on validation\n failure, mapped status on `*xerrors.Err`\n- `HandleNoBody[Res any](fn func(ctx context.Context) (Res, error)) http.HandlerFunc`\n — no body decoding/validation; encodes response directly\n- `HandleEmpty[Req any](v valid.Validator, fn func(ctx context.Context, req Req) error) http.HandlerFunc`\n — decodes and validates body, calls `fn`, returns 204 on success\n- `JSON(w http.ResponseWriter, status int, v any)` — writes JSON response\n- `NoContent(w http.ResponseWriter)` — writes 204 with no body\n- `Error(w http.ResponseWriter, err error)` — maps `*xerrors.Err` to HTTP status and\n writes `{\"code\":\"\u003cwire_value\u003e\",\"message\":\"\u003cmsg\u003e\"}` JSON body; complete 16-code mapping\n\n#### `health`\n\n- `Config` struct — `CheckTimeout time.Duration` with\n `env:\"EINHERJAR_HEALTH_CHECK_TIMEOUT\" envDefault:\"5s\"` (`caarlos0/env` syntax)\n- `Response` struct — `Status string`, `Components map[string]ComponentStatus`\n- `ComponentStatus` struct — `Status`, `Latency` (omitempty), `Error` (omitempty)\n- `NewHandler(logger logging.Logger, checks ...observability.Checkable) http.Handler`\n — shorthand with default 5s timeout\n- `NewHandlerWithConfig(logger logging.Logger, cfg Config, checks ...observability.Checkable) http.Handler`\n — all checks run concurrently in goroutines with a shared context timeout; results\n collected via buffered channel; `DOWN` (critical priority) → 503; `DEGRADED`\n (degraded priority) → 200; `UP` → 200\n- Accepts `observability.Checkable` from `contracts` directly — no local redefinition\n\n#### Root package (`web`)\n\n- `Config` struct — `Server server.Config`, `AllowedOrigins []string` with\n `env:\"EINHERJAR_SERVER_CORS_ORIGINS\" envSeparator:\",\"`\n- `New(logger logging.Logger, cfg ...Config) server.Server` — pre-wires recommended\n middleware stack: Recover → RequestID (UUID v7 with v4 fallback) → RequestLogger →\n CORS (only when `AllowedOrigins` non-empty)\n- Unexported `newRequestID()` — uses `uuid.NewV7()` (time-ordered), falls back to\n `uuid.NewString()` (v4) on generation error\n\n### Design Notes\n\n1. **Progressive disclosure.** `web.New` is the happy path — one call, all middleware\n pre-wired, env vars respected. `server.New` is the escape hatch — every choice\n explicit. Both tiers share the same config structs, env variables, and lifecycle\n contract.\n\n2. **`RateLimiterStore` interface.** The pluggable backend design lets developers start\n with `InMemoryRateLimiterStore` (zero extra dependencies) and swap to a distributed\n store (e.g., `cache-valkey`) at scale without touching middleware wiring. The store\n satisfies the interface via Go duck typing — `cache-valkey` never imports `web/mw`.\n\n3. **Fail-open rate limiting.** When the store returns an error (e.g., Valkey\n unavailable), the request is allowed. Availability is preferred over hard\n enforcement during infrastructure degradation.\n\n4. **`observability.Checkable` from contracts.** `health.NewHandler` accepts\n `observability.Checkable` directly from `contracts/observability`. Any starter\n (`db-*`, `cache-*`, `storage-*`) that implements the contracts interface plugs in\n without an adapter — no `web` import required by those starters.\n\n5. **`last_seen` excluded.** Session tracking is an application-domain concern, not\n transport-level middleware. It requires knowing which entity to track and where to\n persist it. Developers who need it can write it in ~15 lines in their own wiring\n package. Will be revisited if `einherjar/worker` provides a fire-and-forget\n primitive.\n\n6. **UUID v7 for request IDs.** Time-ordered UUIDs embed a millisecond-precision\n timestamp, enabling request IDs to sort chronologically in log aggregation systems.\n UUID v4 fallback ensures ID generation never fails.\n\n---\n\n[1.0.0]: https://code.nochebuena.dev/einherjar/web/releases/tag/v1.0.0\n"
|
||
},
|
||
{
|
||
"name": "worker",
|
||
"importPath": "code.nochebuena.dev/einherjar/worker",
|
||
"purpose": "In Valhalla, there is no rest between battles. There is only preparation for the next.",
|
||
"doc": "Package worker provides a concurrent goroutine pool with lifecycle management.\n\n# Lifecycle\n\nThe component follows the lifecycle.Component contract:\n - OnInit: logs pool configuration; no goroutines are started.\n - OnStart: launches PoolSize goroutines that consume from the task queue.\n - OnStop: closes the task queue, cancels the pool context, then waits up to\n ShutdownTimeout for all goroutines to finish. Returns nil regardless of\n whether the drain completed before the deadline.\n\nAppend to a launcher before starting:\n\n\tpool := worker.New(logger, cfg)\n\tlc.Append(pool)\n\n# Dispatching Tasks\n\nDispatch is non-blocking. It returns false immediately when the buffer is full.\nA false return means the task was dropped — the caller is responsible for\nretry or overflow handling.\n\n\tok := pool.Dispatch(func(ctx context.Context) error {\n\t return sendEmail(ctx, msg)\n\t})\n\tif !ok {\n\t // queue was full; handle backpressure\n\t}\n\n# Interface Segregation\n\nInject Provider into callers that only dispatch work.\nPass Component to the launcher registration site.\n\n# Configuration\n\n\tEINHERJAR_WORKER_POOL_SIZE — number of concurrent goroutines; default 5\n\tEINHERJAR_WORKER_BUFFER_SIZE — task queue capacity; default 100\n\tEINHERJAR_WORKER_TASK_TIMEOUT — per-task deadline (0 = no deadline); default 0s\n\tEINHERJAR_WORKER_SHUTDOWN_TIMEOUT — OnStop drain deadline; default 30s",
|
||
"goVersion": "1.26",
|
||
"dependsOn": [
|
||
"contracts"
|
||
],
|
||
"subPackages": [
|
||
{
|
||
"name": "",
|
||
"importPath": "code.nochebuena.dev/einherjar/worker",
|
||
"doc": "Package worker provides a concurrent goroutine pool with lifecycle management.\n\n# Lifecycle\n\nThe component follows the lifecycle.Component contract:\n - OnInit: logs pool configuration; no goroutines are started.\n - OnStart: launches PoolSize goroutines that consume from the task queue.\n - OnStop: closes the task queue, cancels the pool context, then waits up to\n ShutdownTimeout for all goroutines to finish. Returns nil regardless of\n whether the drain completed before the deadline.\n\nAppend to a launcher before starting:\n\n\tpool := worker.New(logger, cfg)\n\tlc.Append(pool)\n\n# Dispatching Tasks\n\nDispatch is non-blocking. It returns false immediately when the buffer is full.\nA false return means the task was dropped — the caller is responsible for\nretry or overflow handling.\n\n\tok := pool.Dispatch(func(ctx context.Context) error {\n\t return sendEmail(ctx, msg)\n\t})\n\tif !ok {\n\t // queue was full; handle backpressure\n\t}\n\n# Interface Segregation\n\nInject Provider into callers that only dispatch work.\nPass Component to the launcher registration site.\n\n# Configuration\n\n\tEINHERJAR_WORKER_POOL_SIZE — number of concurrent goroutines; default 5\n\tEINHERJAR_WORKER_BUFFER_SIZE — task queue capacity; default 100\n\tEINHERJAR_WORKER_TASK_TIMEOUT — per-task deadline (0 = no deadline); default 0s\n\tEINHERJAR_WORKER_SHUTDOWN_TIMEOUT — OnStop drain deadline; default 30s"
|
||
}
|
||
],
|
||
"symbols": [
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Component",
|
||
"signature": "type Component interface",
|
||
"doc": "Component adds lifecycle management to Provider.\nAppend it to a launcher (lc.Append) before starting.",
|
||
"file": "component.go",
|
||
"line": 10,
|
||
"methods": [
|
||
{
|
||
"signature": "lifecycle.Component"
|
||
},
|
||
{
|
||
"signature": "observability.Identifiable"
|
||
},
|
||
{
|
||
"signature": "Provider"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "New",
|
||
"signature": "func New(logger logging.Logger, cfg Config) Component",
|
||
"doc": "New returns a Component backed by the given config.\nThe goroutine pool is not started until OnStart is called.",
|
||
"file": "new.go",
|
||
"line": 18
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "Config",
|
||
"signature": "type Config struct",
|
||
"doc": "Config holds worker pool settings.",
|
||
"file": "config.go",
|
||
"line": 6,
|
||
"fields": [
|
||
{
|
||
"name": "PoolSize",
|
||
"type": "int",
|
||
"tag": "env:\"EINHERJAR_WORKER_POOL_SIZE\" envDefault:\"5\"",
|
||
"doc": "PoolSize is the number of concurrent goroutines. Default: 5."
|
||
},
|
||
{
|
||
"name": "BufferSize",
|
||
"type": "int",
|
||
"tag": "env:\"EINHERJAR_WORKER_BUFFER_SIZE\" envDefault:\"100\"",
|
||
"doc": "BufferSize is the task queue capacity. Default: 100."
|
||
},
|
||
{
|
||
"name": "TaskTimeout",
|
||
"type": "time.Duration",
|
||
"tag": "env:\"EINHERJAR_WORKER_TASK_TIMEOUT\" envDefault:\"0s\"",
|
||
"doc": "TaskTimeout is the maximum duration for a single task. Zero means no deadline."
|
||
},
|
||
{
|
||
"name": "ShutdownTimeout",
|
||
"type": "time.Duration",
|
||
"tag": "env:\"EINHERJAR_WORKER_SHUTDOWN_TIMEOUT\" envDefault:\"30s\"",
|
||
"doc": "ShutdownTimeout is how long OnStop waits for goroutines to drain. Default: 30s."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"kind": "func",
|
||
"name": "DefaultConfig",
|
||
"signature": "func DefaultConfig() Config",
|
||
"doc": "DefaultConfig returns a production-safe worker pool configuration.",
|
||
"file": "config.go",
|
||
"line": 18
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"kind": "interface",
|
||
"name": "Provider",
|
||
"signature": "type Provider interface",
|
||
"doc": "Provider dispatches tasks to the pool.\nInject Provider into callers that only need to submit work;\nuse Component at the launcher registration site.",
|
||
"file": "provider.go",
|
||
"line": 6,
|
||
"methods": [
|
||
{
|
||
"name": "Dispatch",
|
||
"signature": "Dispatch(task Task) bool",
|
||
"doc": "Dispatch queues a task. Returns false if the queue is full (backpressure)."
|
||
},
|
||
{
|
||
"name": "Len",
|
||
"signature": "Len() int",
|
||
"doc": "Len returns the current number of tasks waiting in the queue."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "Task",
|
||
"signature": "type Task func(ctx context.Context) error",
|
||
"doc": "Task is a unit of work executed asynchronously by the pool.",
|
||
"file": "task.go",
|
||
"line": 6
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"kind": "type",
|
||
"name": "workerImpl",
|
||
"signature": "type workerImpl struct",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 35,
|
||
"fields": [
|
||
{
|
||
"name": "logger",
|
||
"type": "logging.Logger"
|
||
},
|
||
{
|
||
"name": "cfg",
|
||
"type": "Config"
|
||
},
|
||
{
|
||
"name": "taskQueue",
|
||
"type": "chan Task"
|
||
},
|
||
{
|
||
"name": "wg",
|
||
"type": "sync.WaitGroup"
|
||
},
|
||
{
|
||
"name": "ctx",
|
||
"type": "context.Context"
|
||
},
|
||
{
|
||
"name": "cancel",
|
||
"type": "context.CancelFunc"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "workerImpl.Dispatch",
|
||
"signature": "func (w *workerImpl) Dispatch(task Task) bool",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 86
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "workerImpl.Len",
|
||
"signature": "func (w *workerImpl) Len() int",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 84
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "workerImpl.ModulePath",
|
||
"signature": "func (w *workerImpl) ModulePath() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 7
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "workerImpl.ModuleVersion",
|
||
"signature": "func (w *workerImpl) ModuleVersion() string",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 9
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "workerImpl.OnInit",
|
||
"signature": "func (w *workerImpl) OnInit() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 44
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "workerImpl.OnStart",
|
||
"signature": "func (w *workerImpl) OnStart() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 51
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "workerImpl.OnStop",
|
||
"signature": "func (w *workerImpl) OnStop() error",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 63
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"kind": "method",
|
||
"name": "workerImpl.runWorker",
|
||
"signature": "func (w *workerImpl) runWorker(id int)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 96
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"kind": "const",
|
||
"name": "modulePath",
|
||
"signature": "const modulePath = \"code.nochebuena.dev/einherjar/worker\"",
|
||
"doc": "",
|
||
"file": "identifiable.go",
|
||
"line": 5
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"kind": "var",
|
||
"name": "_",
|
||
"signature": "var _ observability.Identifiable = (*workerImpl)(nil)",
|
||
"doc": "",
|
||
"file": "new.go",
|
||
"line": 14
|
||
}
|
||
],
|
||
"adrs": null,
|
||
"examples": [
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"title": "Setup",
|
||
"code": "import \"code.nochebuena.dev/einherjar/worker\"\n\nw := worker.New(logger, worker.DefaultConfig())\nlc.Append(w) // OnInit starts pool; OnStop drains and stops",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"title": "Dispatching tasks",
|
||
"code": "// Task is func(ctx context.Context) error\ndispatched := w.Dispatch(func(ctx context.Context) error {\n return sendWelcomeEmail(ctx, userID)\n})\n\nif !dispatched {\n // Buffer full — decide: drop, queue elsewhere, or return an error.\n logger.Warn(\"worker buffer full, task dropped\", nil)\n}",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"title": "Checking queue depth",
|
||
"code": "depth := w.Len() // number of tasks currently buffered (not yet picked up)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"title": "Checking pool saturation",
|
||
"code": "if w.Len() \u003e= cfg.BufferSize {\n // Near capacity — consider shedding load.\n}",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"title": "Dependency graph",
|
||
"code": "contracts (zero dependencies)\n ↑\n worker (contracts only)\n ↑\n your app",
|
||
"language": ""
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"subPackage": "",
|
||
"title": "Verification",
|
||
"code": "cd worker/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .",
|
||
"language": "bash"
|
||
}
|
||
],
|
||
"compliance": {
|
||
"interfaceAsserts": [
|
||
{
|
||
"module": "worker",
|
||
"interface": "lifecycle.Component",
|
||
"impl": "(Component)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 21
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"interface": "Provider",
|
||
"impl": "(Component)(nil)",
|
||
"file": "compliance_test.go",
|
||
"line": 22
|
||
}
|
||
],
|
||
"tests": [
|
||
{
|
||
"module": "worker",
|
||
"name": "TestAtMostOneExportedTypePerFile",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 26
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"name": "TestDefaultConfig_OptionalFields",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 60
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"name": "TestNew_NotNil",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 76
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"name": "TestNew_AppliesDefaults",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 82
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"name": "TestWorker_DispatchAndExecute",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 94
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"name": "TestWorker_BackpressureFull",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 111
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"name": "TestWorker_OnStop_DrainsQueue",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 126
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"name": "TestWorker_OnStop_Timeout",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 143
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"name": "TestWorker_TaskTimeout",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 161
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"name": "TestWorker_MultipleWorkers",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 191
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"name": "TestWorker_TaskError",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 219
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"name": "TestWorker_Len",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 236
|
||
},
|
||
{
|
||
"module": "worker",
|
||
"name": "TestWorker_Lifecycle",
|
||
"doc": "",
|
||
"file": "compliance_test.go",
|
||
"line": 252
|
||
}
|
||
]
|
||
},
|
||
"readme": "# einherjar/worker\n\n[](https://code.nochebuena.dev/einherjar/worker)\n[](LICENSE)\n[](https://go.dev)\n\n\u003e In Valhalla, there is no rest between battles. There is only preparation for the next.\n\n`code.nochebuena.dev/einherjar/worker` is the background task pool component of the Einherjar framework. It manages a bounded goroutine pool with a buffered work queue. On shutdown, it drains all queued tasks within the configured timeout before stopping. `Dispatch` returns `false` when the buffer is full — the caller decides whether to drop, queue elsewhere, or surface an error.\n\n---\n\n## Usage\n\n### Setup\n\n```go\nimport \"code.nochebuena.dev/einherjar/worker\"\n\nw := worker.New(logger, worker.DefaultConfig())\nlc.Append(w) // OnInit starts pool; OnStop drains and stops\n```\n\n### Dispatching tasks\n\n```go\n// Task is func(ctx context.Context) error\ndispatched := w.Dispatch(func(ctx context.Context) error {\n return sendWelcomeEmail(ctx, userID)\n})\n\nif !dispatched {\n // Buffer full — decide: drop, queue elsewhere, or return an error.\n logger.Warn(\"worker buffer full, task dropped\", nil)\n}\n```\n\n### Checking queue depth\n\n```go\ndepth := w.Len() // number of tasks currently buffered (not yet picked up)\n```\n\n### Checking pool saturation\n\n```go\nif w.Len() \u003e= cfg.BufferSize {\n // Near capacity — consider shedding load.\n}\n```\n\n---\n\n## Environment variables\n\n| Variable | Required | Default | Description |\n|---|---|---|---|\n| `EINHERJAR_WORKER_POOL_SIZE` | No | `5` | Number of goroutines in the pool |\n| `EINHERJAR_WORKER_BUFFER_SIZE` | No | `100` | Buffered task queue capacity |\n| `EINHERJAR_WORKER_TASK_TIMEOUT` | No | `0s` | Per-task deadline; `0` means no deadline |\n| `EINHERJAR_WORKER_SHUTDOWN_TIMEOUT` | No | `30s` | Maximum drain time on stop |\n\nA `PoolSize` of 5 and `BufferSize` of 100 means up to 105 tasks can be in flight (5 executing + 100 waiting) before `Dispatch` returns `false`.\n\n---\n\n## Dependency graph\n\n```\ncontracts (zero dependencies)\n ↑\n worker (contracts only)\n ↑\n your app\n```\n\n`worker` does not depend on `core`. It is the lightest lifecycle component in the framework.\n\n---\n\n## Verification\n\n```bash\ncd worker/\ngo build ./...\ngo vet ./...\ngo test ./...\ngofmt -l .\n```\n\n---\n\n\u003e *A warrior who does not prepare between battles is not a warrior.*\n\u003e *Build the pool. Fill it with work. Let it run.*\n",
|
||
"changelog": "# Changelog — einherjar/worker\n\nAll notable changes to this module are documented here.\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\nThis module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n---\n\n## [1.3.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts` to v1.3.0.\n\n## [1.2.0] — 2026-08-08\n\nMinor — coordinated framework version alignment. No code or API changes in this module.\n\n### Changed\n\n- Bumped `contracts` to v1.2.0.\n\n## [1.1.3] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (\\`contracts\\`) to v1.1.3. No code or API changes.## [1.1.2] — 2026-08-08\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`) to v1.1.2. No code or API changes.\n\n## [1.1.1] — 2026-08-07\n\nPatch — corrected package-doc wiring examples plus framework version alignment.\n\n### Fixed\n\n- Doc examples used a fictional `launcher.Register` / `health.Register` API; corrected to\n `lc.Append` + `web/health.NewHandler(...).ServeHTTP`.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`) to v1.1.1.\n\n## [1.1.0] — 2026-08-07\n\nPatch — coordinated framework version alignment.\n\n### Changed\n\n- Bumped einherjar dependencies (`contracts`) to v1.1.0.\n\n## [1.0.0] — 2026-05-28\n\nInitial release. See the README for the full API and the `v1.0.0` git tag for the source.\n"
|
||
},
|
||
{
|
||
"name": "wire",
|
||
"importPath": "(application internal/wire)",
|
||
"purpose": "Forging a service is mostly wiring. Do it the same way every time.",
|
||
"goVersion": "",
|
||
"dependsOn": [],
|
||
"subPackages": [],
|
||
"symbols": [],
|
||
"adrs": [],
|
||
"examples": [
|
||
{
|
||
"module": "wire",
|
||
"subPackage": "",
|
||
"title": "Project layout",
|
||
"code": "cmd/\u003capp\u003e/main.go one-line entrypoint: godotenv autoload + wire.Run()\ninternal/wire/wire.go Run() — loads config, builds infra, registers feature hooks\ninternal/wire/\u003cfeature\u003e.go one file per feature, hosts a with\u003cFeature\u003e hook\ninternal/wire/middleware.go authz, skipPublicPaths, skipMethodPath helpers\ninternal/config/config.go global Config composing framework component configs\n.env.example every env var the config reads, documented, in sync\ninternal/\u003cfeature\u003e/dto/ request/response DTOs\ninternal/\u003cfeature\u003e/handler/ HTTP handlers\ninternal/\u003cfeature\u003e/repository/ data access\ninternal/\u003cfeature\u003e/service/ domain logic",
|
||
"language": ""
|
||
},
|
||
{
|
||
"module": "wire",
|
||
"subPackage": "",
|
||
"title": "main.go",
|
||
"code": "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t_ \"github.com/joho/godotenv/autoload\"\n\n\t\"myapp/internal/wire\"\n)\n\nfunc main() {\n\tif err := wire.Run(); err != nil {\n\t\tfmt.Fprintln(os.Stderr, \"fatal:\", err)\n\t\tos.Exit(1)\n\t}\n}",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "wire",
|
||
"subPackage": "",
|
||
"title": "Config",
|
||
"code": "package config\n\nimport (\n\t\"time\"\n\n\t\"github.com/caarlos0/env/v11\"\n\n\t\"code.nochebuena.dev/einherjar/core/logz\"\n\t\"code.nochebuena.dev/einherjar/db-postgres\"\n\t\"code.nochebuena.dev/einherjar/web/server\"\n)\n\n// JWTConfig is app-owned: the secret is handed to the signer in code, not consumed\n// by a framework component, so it carries no EINHERJAR_ prefix.\ntype JWTConfig struct {\n\tSecret string `env:\"APP_JWT_SECRET,required,notEmpty\"`\n\tIssuer string `env:\"APP_JWT_ISSUER\" envDefault:\"myapp\"`\n\tAccessTTL time.Duration `env:\"APP_JWT_ACCESS_TTL\" envDefault:\"1h\"`\n\tRefreshTTL time.Duration `env:\"APP_JWT_REFRESH_TTL\" envDefault:\"168h\"`\n}\n\n// Config is the fully-resolved startup configuration. Einherjar component configs\n// are nested fields; caarlos0/env recurses into them, populating their\n// EINHERJAR_SERVER_* / EINHERJAR_PG_* tags from the environment.\ntype Config struct {\n\tAppEnv string `env:\"APP_ENV\" envDefault:\"local\"`\n\n\tJWT JWTConfig\n\n\t// Framework component configs — composed verbatim. Their own EINHERJAR_* tags\n\t// load through this one env.Parse call.\n\tLog logz.Config // EINHERJAR_LOG_*\n\tServer server.Config // EINHERJAR_SERVER_* (incl. EINHERJAR_SERVER_CORS_ORIGINS)\n\tPG postgres.Config // EINHERJAR_PG_*\n}\n\nfunc Load() (Config, error) {\n\tvar cfg Config\n\tif err := env.Parse(\u0026cfg); err != nil {\n\t\treturn Config{}, err\n\t}\n\treturn cfg, nil\n}",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "wire",
|
||
"subPackage": "",
|
||
"title": "Config \u0026 .env.example",
|
||
"code": "# .env.example — copy to .env for local dev. Every var the app reads lives here.\n\n# ── App ───────────────────────────────────────────────────────────────────\nAPP_ENV=local\n# CORS: local uses mw.CORSAllowAll(); non-local reads EINHERJAR_SERVER_CORS_ORIGINS (below).\nAPP_JWT_SECRET=change-me\nAPP_JWT_ISSUER=myapp\n\n# ── Einherjar: logging (EINHERJAR_LOG_*) ──────────────────────────────────\n# EINHERJAR_LOG_LEVEL=INFO\n# EINHERJAR_LOG_JSON=false\n\n# ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ───────────────────────────\nEINHERJAR_SERVER_HOST=0.0.0.0\nEINHERJAR_SERVER_PORT=8080\n# EINHERJAR_SERVER_CORS_ORIGINS — explicit origins for non-local envs (comma-separated).\n# \"*\" is rejected by mw.CORS; local dev uses mw.CORSAllowAll() and ignores this.\n# EINHERJAR_SERVER_CORS_ORIGINS=\n\n# ── Einherjar: PostgreSQL (EINHERJAR_PG_*) ────────────────────────────────\nEINHERJAR_PG_HOST=localhost\nEINHERJAR_PG_PORT=5432\nEINHERJAR_PG_USER=postgres\nEINHERJAR_PG_PASSWORD=postgres\nEINHERJAR_PG_NAME=myapp",
|
||
"language": "bash"
|
||
},
|
||
{
|
||
"module": "wire",
|
||
"subPackage": "",
|
||
"title": "wire.go — Run()",
|
||
"code": "\u003e // v1.x (removed) — compiled but could serve no CORS after v1.2.0:\n\u003e // mw.CORS(cfg.Web.AllowedOrigins)\n\u003e // web.New(logger, web.Config{AllowedOrigins: origins})\n\u003e\n\u003e // v1.3.0 — the single source of truth:\n\u003e mw.CORS(cfg.Server.CORSOrigins) // server.New tier\n\u003e web.New(logger, web.Config{Server: cfg.Server}) // web.New reads it automatically\n\u003e",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "wire",
|
||
"subPackage": "",
|
||
"title": "wire.go — Run()",
|
||
"code": "package wire\n\nimport (\n\t\"github.com/google/uuid\"\n\n\tauthjwt \"code.nochebuena.dev/einherjar/auth-jwt\"\n\t\"code.nochebuena.dev/einherjar/auth/authmw\"\n\t\"code.nochebuena.dev/einherjar/auth/rbac\"\n\t\"code.nochebuena.dev/einherjar/core/launcher\"\n\t\"code.nochebuena.dev/einherjar/core/logz\"\n\t\"code.nochebuena.dev/einherjar/core/valid\"\n\t\"code.nochebuena.dev/einherjar/db-postgres\"\n\t\"code.nochebuena.dev/einherjar/web/mw\"\n\t\"code.nochebuena.dev/einherjar/web/server\"\n\n\t\"myapp/internal/config\"\n)\n\nfunc Run() error {\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// logz.Config is composed in config, so EINHERJAR_LOG_LEVEL / _JSON load from\n\t// the environment; StaticArgs are set here (they carry no env tag).\n\tlogCfg := cfg.Log\n\tlogCfg.StaticArgs = []any{\"service\", \"myapp\", \"env\", cfg.AppEnv}\n\tlogger := logz.New(logCfg)\n\n\tsigner := authjwt.NewHMACSigner([]byte(cfg.JWT.Secret))\n\n\tpublicPaths := []string{\n\t\t\"/health\",\n\t\t\"/api/v1/auth/login\",\n\t\t\"/api/v1/auth/refresh\",\n\t}\n\n\tdb := postgres.New(logger, cfg.PG)\n\n\t// CORS convention: allow-all in local dev, explicit origins everywhere else.\n\t// Origins come from the framework's own EINHERJAR_SERVER_CORS_ORIGINS\n\t// (cfg.Server.CORSOrigins) — never invent an APP_CORS_ORIGINS var. mw.CORS panics\n\t// on \"*\" (it matches no real origin) — allow-all is mw.CORSAllowAll, never a \"*\".\n\tcorsMW := mw.CORSAllowAll()\n\tif !strings.EqualFold(cfg.AppEnv, \"local\") {\n\t\tcorsMW = mw.CORS(cfg.Server.CORSOrigins)\n\t}\n\n\tsrv := server.New(logger, cfg.Server,\n\t\tserver.WithMiddleware(\n\t\t\tmw.RequestID(uuid.NewString),\n\t\t\tmw.Recover(logger),\n\t\t\tcorsMW,\n\t\t\tmw.RequestLogger(logger),\n\t\t\tauthjwt.AuthMiddleware(logger, signer, publicPaths),\n\t\t\tauthmw.EnrichmentMiddleware(logger, \u0026claimsEnricher{}),\n\t\t),\n\t)\n\n\tv := valid.New(valid.WithMessageProvider(valid.SpanishMessages))\n\tprovider := rbac.NewClaimsPermissionProvider(\"masks\", claimsFromCtx)\n\n\tlc := launcher.New(logger)\n\tlc.Append(db, srv)\n\n\twithHealth(lc, srv, logger, db)\n\twithUsers(lc, srv, db, logger, provider, v)\n\t// … one withFeature(...) call per feature in your domain.\n\n\treturn lc.Run()\n}",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "wire",
|
||
"subPackage": "",
|
||
"title": "Feature hook",
|
||
"code": "package wire\n\nimport (\n\t\"code.nochebuena.dev/einherjar/contracts/logging\"\n\t\"code.nochebuena.dev/einherjar/contracts/security\"\n\t\"code.nochebuena.dev/einherjar/core/launcher\"\n\t\"code.nochebuena.dev/einherjar/core/valid\"\n\t\"code.nochebuena.dev/einherjar/db-postgres\"\n\t\"code.nochebuena.dev/einherjar/web/server\"\n\n\t\"myapp/internal/domains\"\n\tuserhandler \"myapp/internal/user/handler\"\n\tuserrepo \"myapp/internal/user/repository\"\n\tusersvc \"myapp/internal/user/service\"\n)\n\nfunc withUsers(\n\tlc launcher.Launcher,\n\tsrv server.Server,\n\tdb postgres.Provider,\n\tlogger logging.Logger,\n\tprovider security.PermissionProvider,\n\tv valid.Validator,\n) {\n\tlc.BeforeStart(func() error {\n\t\trepo := userrepo.New(db)\n\t\tuow := postgres.NewUnitOfWork(logger, db)\n\t\tsvc := usersvc.New(repo, uow)\n\t\th := userhandler.New(svc, v)\n\n\t\t// Literal-segment routes register BEFORE parametrised siblings.\n\t\tsrv.Put(\"/api/v1/users/me/password\", h.ChangeOwnPassword)\n\n\t\tsrv.With(authz(provider, domains.ResourceUsers, domains.GrantReadUser)).\n\t\t\tGet(\"/api/v1/users\", h.ListUsers)\n\t\tsrv.With(authz(provider, domains.ResourceUsers, domains.GrantCreateUser)).\n\t\t\tPost(\"/api/v1/users\", h.CreateUser)\n\t\tsrv.With(authz(provider, domains.ResourceUsers, domains.GrantUpdateUser)).\n\t\t\tPut(\"/api/v1/users/{user_id}\", h.UpdateUser)\n\n\t\treturn nil\n\t})\n}",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "wire",
|
||
"subPackage": "",
|
||
"title": "Route ordering",
|
||
"code": "srv.Put(\"/api/v1/users/me/password\", h.ChangeOwnPassword)\nsrv.Put(\"/api/v1/users/{user_id}\", h.UpdateUser)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "wire",
|
||
"subPackage": "",
|
||
"title": "Route ordering",
|
||
"code": "srv.Put(\"/api/v1/users/{user_id}\", h.UpdateUser)\nsrv.Put(\"/api/v1/users/me/password\", h.ChangeOwnPassword)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "wire",
|
||
"subPackage": "",
|
||
"title": "Authorization",
|
||
"code": "srv.With(authz(provider, domains.ResourceUsers, domains.GrantReadUser)).\n Get(\"/api/v1/users\", h.ListUsers)",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "wire",
|
||
"subPackage": "",
|
||
"title": "Middleware helpers",
|
||
"code": "// authz returns a per-route authorization middleware that checks one bit.\nfunc authz(p security.PermissionProvider, resource string, bit int) func(http.Handler) http.Handler {\n\treturn authmw.AuthzMiddleware(nil, p, resource, security.Permission(bit))\n}\n\n// skipPublicPaths wraps mw so it is bypassed for any path that matches publicPaths.\n// Use this for middleware that must not run on unauthenticated endpoints\n// (e.g. EnrichmentMiddleware).\nfunc skipPublicPaths(publicPaths []string, mw func(http.Handler) http.Handler) func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\tinner := mw(next)\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tfor _, p := range publicPaths {\n\t\t\t\tif matched, _ := path.Match(p, r.URL.Path); matched {\n\t\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tinner.ServeHTTP(w, r)\n\t\t})\n\t}\n}\n\n// skipMethodPath bypasses mw only when BOTH method and path match. Use this to\n// expose ONE method on an otherwise-authenticated path (e.g. GET /api/v1/config\n// public while PUT is not). Adding such a path to publicPaths would silently\n// strip identity from context on the protected methods, breaking authz().\nfunc skipMethodPath(method, pathPattern string, mw func(http.Handler) http.Handler) func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\tinner := mw(next)\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tif r.Method == method {\n\t\t\t\tif matched, _ := path.Match(pathPattern, r.URL.Path); matched {\n\t\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tinner.ServeHTTP(w, r)\n\t\t})\n\t}\n}",
|
||
"language": "go"
|
||
},
|
||
{
|
||
"module": "wire",
|
||
"subPackage": "",
|
||
"title": "Adapters at the wire boundary",
|
||
"code": "import (\n\t\"time\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n\t\"github.com/google/uuid\"\n\n\tauthjwt \"code.nochebuena.dev/einherjar/auth-jwt\"\n)\n\ntype tokenSignerAdapter struct {\n\tsigner authjwt.Signer\n\tcfg authjwt.TokenConfig\n}\n\nvar _ authsvc.TokenSigner = (*tokenSignerAdapter)(nil)\n\nfunc (a *tokenSignerAdapter) IssueTokenPair(subject string, custom map[string]any) (authdto.TokenPairResponse, error) {\n\tnow := time.Now()\n\n\taccess := jwt.MapClaims{\n\t\t\"sub\": subject,\n\t\t\"iss\": a.cfg.Issuer,\n\t\t\"iat\": now.Unix(),\n\t\t\"exp\": now.Add(a.cfg.AccessTTL).Unix(),\n\t}\n\tfor k, v := range custom {\n\t\taccess[k] = v\n\t}\n\taccessToken, err := a.signer.Sign(access)\n\tif err != nil {\n\t\treturn authdto.TokenPairResponse{}, err\n\t}\n\n\trefreshToken, err := a.signer.Sign(jwt.MapClaims{\n\t\t\"sub\": subject,\n\t\t\"jti\": uuid.NewString(),\n\t\t\"iat\": now.Unix(),\n\t\t\"exp\": now.Add(a.cfg.RefreshTTL).Unix(),\n\t})\n\tif err != nil {\n\t\treturn authdto.TokenPairResponse{}, err\n\t}\n\n\treturn authdto.TokenPairResponse{\n\t\tAccessToken: accessToken,\n\t\tRefreshToken: refreshToken,\n\t\tTokenType: \"Bearer\",\n\t\tExpiresIn: int(a.cfg.AccessTTL.Seconds()),\n\t}, nil\n}",
|
||
"language": "go"
|
||
}
|
||
],
|
||
"compliance": {
|
||
"interfaceAsserts": [],
|
||
"tests": []
|
||
},
|
||
"readme": "# Wiring Conventions\n\n\u003e Forging a service is mostly wiring. Do it the same way every time.\n\nThis is not an Einherjar *module* — it is the canonical *application* shape that uses\nEinherjar modules. Apps live in their own repository with an `internal/wire/` package that\nmirrors this template. The conventions here are distilled from production services built on\nEinherjar v1 (`iron-dough-api`, `pei-api`) and describe the **one opinionated minimum** a\nscaffolded app should have. You *can* hand-roll something else — but then it is yours to\nmaintain, and it will not match what the rest of the ecosystem reads at a glance.\n\n## The opinionated minimum\n\nEvery scaffolded Einherjar application has, at minimum:\n\n1. **A clean `main.go`** — nothing but `.env` autoload and a call to `wire.Run()`.\n2. **An `internal/wire/` package** — one file per feature plus `wire.go`, which assembles everything.\n3. **An `internal/config/config.go`** — one global `Config` that *composes* the framework's\n component configs, loaded from the environment with `caarlos0/env`.\n4. **A `.env.example`** kept in lock-step with that config (see *Config \u0026 .env.example*).\n\nAnything a developer freely chooses — how migrations run, how the first admin is seeded, an\ninit-by-endpoint/webhook/email flow — is **not** part of this convention and is left to the app.\n\n## Project layout\n\n```\ncmd/\u003capp\u003e/main.go one-line entrypoint: godotenv autoload + wire.Run()\ninternal/wire/wire.go Run() — loads config, builds infra, registers feature hooks\ninternal/wire/\u003cfeature\u003e.go one file per feature, hosts a with\u003cFeature\u003e hook\ninternal/wire/middleware.go authz, skipPublicPaths, skipMethodPath helpers\ninternal/config/config.go global Config composing framework component configs\n.env.example every env var the config reads, documented, in sync\ninternal/\u003cfeature\u003e/dto/ request/response DTOs\ninternal/\u003cfeature\u003e/handler/ HTTP handlers\ninternal/\u003cfeature\u003e/repository/ data access\ninternal/\u003cfeature\u003e/service/ domain logic\n```\n\n## main.go\n\n`cmd/\u003capp\u003e/main.go` contains **nothing** but the `.env` autoload and the call to `wire.Run()`.\nThe blank import `_ \"github.com/joho/godotenv/autoload\"` is the standard, documented way to load\na local `.env` — it never overrides variables already set in the real environment, and a missing\nfile is not an error, so deployments (vars injected by the platform, no `.env`) are unaffected.\n\n```go\npackage main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t_ \"github.com/joho/godotenv/autoload\"\n\n\t\"myapp/internal/wire\"\n)\n\nfunc main() {\n\tif err := wire.Run(); err != nil {\n\t\tfmt.Fprintln(os.Stderr, \"fatal:\", err)\n\t\tos.Exit(1)\n\t}\n}\n```\n\nNo config parsing, no component construction, no logging setup — all of that lives in\n`internal/wire/`. A `main.go` that builds anything itself is the single most common scaffolding\nmistake.\n\n## Config\n\n`internal/config/config.go` is **one** `Config` struct that *composes* the framework's component\nconfigs as nested fields, alongside the app's own settings. `caarlos0/env` recurses into the\nnested fields, so each Einherjar component's `EINHERJAR_*` env tags load automatically next to\nthe app-owned fields. App-owned fields use the `APP_*` prefix so they never collide with the\nframework's `EINHERJAR_*` namespace. There is exactly one `Load()`.\n\n```go\npackage config\n\nimport (\n\t\"time\"\n\n\t\"github.com/caarlos0/env/v11\"\n\n\t\"code.nochebuena.dev/einherjar/core/logz\"\n\t\"code.nochebuena.dev/einherjar/db-postgres\"\n\t\"code.nochebuena.dev/einherjar/web/server\"\n)\n\n// JWTConfig is app-owned: the secret is handed to the signer in code, not consumed\n// by a framework component, so it carries no EINHERJAR_ prefix.\ntype JWTConfig struct {\n\tSecret string `env:\"APP_JWT_SECRET,required,notEmpty\"`\n\tIssuer string `env:\"APP_JWT_ISSUER\" envDefault:\"myapp\"`\n\tAccessTTL time.Duration `env:\"APP_JWT_ACCESS_TTL\" envDefault:\"1h\"`\n\tRefreshTTL time.Duration `env:\"APP_JWT_REFRESH_TTL\" envDefault:\"168h\"`\n}\n\n// Config is the fully-resolved startup configuration. Einherjar component configs\n// are nested fields; caarlos0/env recurses into them, populating their\n// EINHERJAR_SERVER_* / EINHERJAR_PG_* tags from the environment.\ntype Config struct {\n\tAppEnv string `env:\"APP_ENV\" envDefault:\"local\"`\n\n\tJWT JWTConfig\n\n\t// Framework component configs — composed verbatim. Their own EINHERJAR_* tags\n\t// load through this one env.Parse call.\n\tLog logz.Config // EINHERJAR_LOG_*\n\tServer server.Config // EINHERJAR_SERVER_* (incl. EINHERJAR_SERVER_CORS_ORIGINS)\n\tPG postgres.Config // EINHERJAR_PG_*\n}\n\nfunc Load() (Config, error) {\n\tvar cfg Config\n\tif err := env.Parse(\u0026cfg); err != nil {\n\t\treturn Config{}, err\n\t}\n\treturn cfg, nil\n}\n```\n\nNever read framework env vars (`EINHERJAR_*`) with `os.Getenv` — compose the component's `Config`\ntype and let `caarlos0/env` load it. A raw `os.Getenv(\"EINHERJAR_PG_HOST\")` in application code is\nthe mistake this convention removes.\n\n## Config \u0026 .env.example\n\nEvery environment variable the `config` package reads **must** also appear in `.env.example` at\nthe repo root, documented. The two are kept in **lock-step**: when a feature introduces a new env\nvar, the same change adds its `env:\"...\"` tag to `config` **and** a documented line to\n`.env.example`. This is not optional bookkeeping — it is what stops a long feature from shipping\nand then failing at boot because nobody knew which variables to set.\n\n**Two kinds of var, two ways to keep them honest:**\n\n- **Framework component vars (`EINHERJAR_*`)** — when you compose a new component later (e.g.\n `cachevalkey.Config`, `minio.Config`, `smtp.Config`), the MCP knows its real vars: call\n `get_config_env(\"\u003cmodule\u003e\")` for the exact set (name, required, default) and add each to\n `.env.example`. The `config.unknown-env-var` rule rejects any `EINHERJAR_*` tag the framework\n doesn't declare, so a typo like `EINHERJAR_PG_DATABASE` is caught at `validate_snippet` time.\n- **App-owned vars (`APP_*`)** — like `APP_JWT_SECRET` above: the framework can't know these, so\n keeping them in `.env.example` is your discipline, not something it can name-check.\n\nAfter you compose a component, run **`check_env`** with what the app composes: it flags\n`EINHERJAR_*` names that don't exist, required vars you forgot to document, and vars set for a\nconfig you don't actually compose (dead vars). Prefer the **`composes`** input (exact struct\nselectors like `web/server/Config`) over `modules` — it catches struct-level dead vars (e.g.\n`EINHERJAR_HEALTH_CHECK_TIMEOUT` lives on `web/health.Config`, so it is dead if you compose\n`web/server/Config` but not the health config). `get_scaffold` already emits a `.env.example`\nderived from these same tags, so the starting point is correct by construction.\n\n```bash\n# .env.example — copy to .env for local dev. Every var the app reads lives here.\n\n# ── App ───────────────────────────────────────────────────────────────────\nAPP_ENV=local\n# CORS: local uses mw.CORSAllowAll(); non-local reads EINHERJAR_SERVER_CORS_ORIGINS (below).\nAPP_JWT_SECRET=change-me\nAPP_JWT_ISSUER=myapp\n\n# ── Einherjar: logging (EINHERJAR_LOG_*) ──────────────────────────────────\n# EINHERJAR_LOG_LEVEL=INFO\n# EINHERJAR_LOG_JSON=false\n\n# ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ───────────────────────────\nEINHERJAR_SERVER_HOST=0.0.0.0\nEINHERJAR_SERVER_PORT=8080\n# EINHERJAR_SERVER_CORS_ORIGINS — explicit origins for non-local envs (comma-separated).\n# \"*\" is rejected by mw.CORS; local dev uses mw.CORSAllowAll() and ignores this.\n# EINHERJAR_SERVER_CORS_ORIGINS=\n\n# ── Einherjar: PostgreSQL (EINHERJAR_PG_*) ────────────────────────────────\nEINHERJAR_PG_HOST=localhost\nEINHERJAR_PG_PORT=5432\nEINHERJAR_PG_USER=postgres\nEINHERJAR_PG_PASSWORD=postgres\nEINHERJAR_PG_NAME=myapp\n```\n\nTo discover the full set for any component you compose, use `get_config_env(\"\u003cmodule\u003e\")` rather\nthan reading source by hand; every var it returns belongs in `.env.example`, and `check_env`\nconfirms none are missing, misspelled, or dead.\n\n## wire.go — Run()\n\nThe application entry point. The order below is load-bearing: configuration first, observability\nsecond, infrastructure third, cross-cutting helpers fourth, then the launcher with every component\nappended, then feature hooks, then `lc.Run()`.\n\n**`web.New` vs `server.New` — pick the right tier:**\n\n- **`web.New(logger, web.Config{Server: cfg.Server})`** — batteries-included. It pre-wires the\n recommended middleware stack (Recover → RequestID → RequestLogger) and applies `mw.CORS` from\n `EINHERJAR_SERVER_CORS_ORIGINS` automatically (explicit origins only; empty ⇒ CORS off + a log\n line). Use it for a plain service that just needs the defaults. It does **not** support allow-all\n CORS or a custom middleware order.\n- **`server.New(logger, cfg.Server, server.WithMiddleware(...))`** — full control. You compose the\n middleware list yourself. Use it when you need a **custom middleware order**, extra middleware\n (auth, enrichment), a custom request-ID generator, or **allow-all CORS in local dev**\n (`mw.CORSAllowAll`, gated by `AppEnv` — see below). The starter below uses `server.New` precisely\n because it inserts JWT auth + enrichment into the stack.\n\nWhichever tier you pick, CORS origins always come from the framework var\n`EINHERJAR_SERVER_CORS_ORIGINS` (`cfg.Server.CORSOrigins`) — never invent an app-owned CORS var.\n\n\u003e **CORS has one home: `server.Config.CORSOrigins`.** In framework `v1.x`, `web.Config`\n\u003e carried an `AllowedOrigins` field. It was env-backed through `v1.1.x` and a code-only override\n\u003e in `v1.2.0` — reading it after the env tag moved silently served *no* CORS. **`v1.3.0` removed the\n\u003e field entirely** so the mistake fails at compile time instead of at runtime. If you are migrating\n\u003e code that read `web.Config.AllowedOrigins` or set it in a struct literal, switch to\n\u003e `cfg.Server.CORSOrigins`:\n\u003e\n\u003e ```go\n\u003e // v1.x (removed) — compiled but could serve no CORS after v1.2.0:\n\u003e // mw.CORS(cfg.Web.AllowedOrigins)\n\u003e // web.New(logger, web.Config{AllowedOrigins: origins})\n\u003e\n\u003e // v1.3.0 — the single source of truth:\n\u003e mw.CORS(cfg.Server.CORSOrigins) // server.New tier\n\u003e web.New(logger, web.Config{Server: cfg.Server}) // web.New reads it automatically\n\u003e ```\n\u003e\n\u003e `validate_snippet` flags any lingering `AllowedOrigins` reference (`web.allowedorigins-removed`).\n\n```go\npackage wire\n\nimport (\n\t\"github.com/google/uuid\"\n\n\tauthjwt \"code.nochebuena.dev/einherjar/auth-jwt\"\n\t\"code.nochebuena.dev/einherjar/auth/authmw\"\n\t\"code.nochebuena.dev/einherjar/auth/rbac\"\n\t\"code.nochebuena.dev/einherjar/core/launcher\"\n\t\"code.nochebuena.dev/einherjar/core/logz\"\n\t\"code.nochebuena.dev/einherjar/core/valid\"\n\t\"code.nochebuena.dev/einherjar/db-postgres\"\n\t\"code.nochebuena.dev/einherjar/web/mw\"\n\t\"code.nochebuena.dev/einherjar/web/server\"\n\n\t\"myapp/internal/config\"\n)\n\nfunc Run() error {\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// logz.Config is composed in config, so EINHERJAR_LOG_LEVEL / _JSON load from\n\t// the environment; StaticArgs are set here (they carry no env tag).\n\tlogCfg := cfg.Log\n\tlogCfg.StaticArgs = []any{\"service\", \"myapp\", \"env\", cfg.AppEnv}\n\tlogger := logz.New(logCfg)\n\n\tsigner := authjwt.NewHMACSigner([]byte(cfg.JWT.Secret))\n\n\tpublicPaths := []string{\n\t\t\"/health\",\n\t\t\"/api/v1/auth/login\",\n\t\t\"/api/v1/auth/refresh\",\n\t}\n\n\tdb := postgres.New(logger, cfg.PG)\n\n\t// CORS convention: allow-all in local dev, explicit origins everywhere else.\n\t// Origins come from the framework's own EINHERJAR_SERVER_CORS_ORIGINS\n\t// (cfg.Server.CORSOrigins) — never invent an APP_CORS_ORIGINS var. mw.CORS panics\n\t// on \"*\" (it matches no real origin) — allow-all is mw.CORSAllowAll, never a \"*\".\n\tcorsMW := mw.CORSAllowAll()\n\tif !strings.EqualFold(cfg.AppEnv, \"local\") {\n\t\tcorsMW = mw.CORS(cfg.Server.CORSOrigins)\n\t}\n\n\tsrv := server.New(logger, cfg.Server,\n\t\tserver.WithMiddleware(\n\t\t\tmw.RequestID(uuid.NewString),\n\t\t\tmw.Recover(logger),\n\t\t\tcorsMW,\n\t\t\tmw.RequestLogger(logger),\n\t\t\tauthjwt.AuthMiddleware(logger, signer, publicPaths),\n\t\t\tauthmw.EnrichmentMiddleware(logger, \u0026claimsEnricher{}),\n\t\t),\n\t)\n\n\tv := valid.New(valid.WithMessageProvider(valid.SpanishMessages))\n\tprovider := rbac.NewClaimsPermissionProvider(\"masks\", claimsFromCtx)\n\n\tlc := launcher.New(logger)\n\tlc.Append(db, srv)\n\n\twithHealth(lc, srv, logger, db)\n\twithUsers(lc, srv, db, logger, provider, v)\n\t// … one withFeature(...) call per feature in your domain.\n\n\treturn lc.Run()\n}\n```\n\n## Feature hook\n\nOne file per feature in `internal/wire/`. The function signature is fixed:\n`launcher.Launcher` first, `server.Server` second when registering routes, deps last. The body is\n*one* call to `lc.BeforeStart`. Everything else — repository, service, handler construction, route\nregistration — lives inside the closure.\n\n```go\npackage wire\n\nimport (\n\t\"code.nochebuena.dev/einherjar/contracts/logging\"\n\t\"code.nochebuena.dev/einherjar/contracts/security\"\n\t\"code.nochebuena.dev/einherjar/core/launcher\"\n\t\"code.nochebuena.dev/einherjar/core/valid\"\n\t\"code.nochebuena.dev/einherjar/db-postgres\"\n\t\"code.nochebuena.dev/einherjar/web/server\"\n\n\t\"myapp/internal/domains\"\n\tuserhandler \"myapp/internal/user/handler\"\n\tuserrepo \"myapp/internal/user/repository\"\n\tusersvc \"myapp/internal/user/service\"\n)\n\nfunc withUsers(\n\tlc launcher.Launcher,\n\tsrv server.Server,\n\tdb postgres.Provider,\n\tlogger logging.Logger,\n\tprovider security.PermissionProvider,\n\tv valid.Validator,\n) {\n\tlc.BeforeStart(func() error {\n\t\trepo := userrepo.New(db)\n\t\tuow := postgres.NewUnitOfWork(logger, db)\n\t\tsvc := usersvc.New(repo, uow)\n\t\th := userhandler.New(svc, v)\n\n\t\t// Literal-segment routes register BEFORE parametrised siblings.\n\t\tsrv.Put(\"/api/v1/users/me/password\", h.ChangeOwnPassword)\n\n\t\tsrv.With(authz(provider, domains.ResourceUsers, domains.GrantReadUser)).\n\t\t\tGet(\"/api/v1/users\", h.ListUsers)\n\t\tsrv.With(authz(provider, domains.ResourceUsers, domains.GrantCreateUser)).\n\t\t\tPost(\"/api/v1/users\", h.CreateUser)\n\t\tsrv.With(authz(provider, domains.ResourceUsers, domains.GrantUpdateUser)).\n\t\t\tPut(\"/api/v1/users/{user_id}\", h.UpdateUser)\n\n\t\treturn nil\n\t})\n}\n```\n\n## Route ordering\n\nchi matches paths in registration order. Always register literal-segment routes before\nparametrised-segment routes that share the same prefix.\n\n✅ Correct:\n\n```go\nsrv.Put(\"/api/v1/users/me/password\", h.ChangeOwnPassword)\nsrv.Put(\"/api/v1/users/{user_id}\", h.UpdateUser)\n```\n\n❌ Wrong — chi binds `me` to `{user_id}` and the literal route is unreachable:\n\n```go\nsrv.Put(\"/api/v1/users/{user_id}\", h.UpdateUser)\nsrv.Put(\"/api/v1/users/me/password\", h.ChangeOwnPassword)\n```\n\n## Authorization\n\nEvery protected route registers with `.With(authz(provider, resource, grant))`:\n\n```go\nsrv.With(authz(provider, domains.ResourceUsers, domains.GrantReadUser)).\n Get(\"/api/v1/users\", h.ListUsers)\n```\n\nResource constants and grant bits live in `internal/domains/`. Routes that the caller owns\n(`/me/...`) intentionally skip authz — they are reachable to any authenticated user.\n\n## Middleware helpers\n\nThese belong in `internal/wire/middleware.go` and are used across every feature hook.\n\n```go\n// authz returns a per-route authorization middleware that checks one bit.\nfunc authz(p security.PermissionProvider, resource string, bit int) func(http.Handler) http.Handler {\n\treturn authmw.AuthzMiddleware(nil, p, resource, security.Permission(bit))\n}\n\n// skipPublicPaths wraps mw so it is bypassed for any path that matches publicPaths.\n// Use this for middleware that must not run on unauthenticated endpoints\n// (e.g. EnrichmentMiddleware).\nfunc skipPublicPaths(publicPaths []string, mw func(http.Handler) http.Handler) func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\tinner := mw(next)\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tfor _, p := range publicPaths {\n\t\t\t\tif matched, _ := path.Match(p, r.URL.Path); matched {\n\t\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tinner.ServeHTTP(w, r)\n\t\t})\n\t}\n}\n\n// skipMethodPath bypasses mw only when BOTH method and path match. Use this to\n// expose ONE method on an otherwise-authenticated path (e.g. GET /api/v1/config\n// public while PUT is not). Adding such a path to publicPaths would silently\n// strip identity from context on the protected methods, breaking authz().\nfunc skipMethodPath(method, pathPattern string, mw func(http.Handler) http.Handler) func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\tinner := mw(next)\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tif r.Method == method {\n\t\t\t\tif matched, _ := path.Match(pathPattern, r.URL.Path); matched {\n\t\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tinner.ServeHTTP(w, r)\n\t\t})\n\t}\n}\n```\n\n## Adapters at the wire boundary\n\nWhen a framework type does not match a service-layer port, write a small typed adapter in\n`internal/wire/`. Always compile-time assert with `var _ TargetIface = (*adapter)(nil)`.\n\nThe framework intentionally exposes only `Signer.Sign(claims) (string, error)` — **the framework\ngives you a signing primitive; the access/refresh strategy, claim layout, and response shape are\napplication concerns.** A \"helper\" that returned a fixed `{access, refresh, type, expiresIn}` struct\nwould silently decide for every app whether refresh tokens exist, what fields to expose, and what\ncasing to use. Those are wire-format choices the app owns.\n\n```go\nimport (\n\t\"time\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n\t\"github.com/google/uuid\"\n\n\tauthjwt \"code.nochebuena.dev/einherjar/auth-jwt\"\n)\n\ntype tokenSignerAdapter struct {\n\tsigner authjwt.Signer\n\tcfg authjwt.TokenConfig\n}\n\nvar _ authsvc.TokenSigner = (*tokenSignerAdapter)(nil)\n\nfunc (a *tokenSignerAdapter) IssueTokenPair(subject string, custom map[string]any) (authdto.TokenPairResponse, error) {\n\tnow := time.Now()\n\n\taccess := jwt.MapClaims{\n\t\t\"sub\": subject,\n\t\t\"iss\": a.cfg.Issuer,\n\t\t\"iat\": now.Unix(),\n\t\t\"exp\": now.Add(a.cfg.AccessTTL).Unix(),\n\t}\n\tfor k, v := range custom {\n\t\taccess[k] = v\n\t}\n\taccessToken, err := a.signer.Sign(access)\n\tif err != nil {\n\t\treturn authdto.TokenPairResponse{}, err\n\t}\n\n\trefreshToken, err := a.signer.Sign(jwt.MapClaims{\n\t\t\"sub\": subject,\n\t\t\"jti\": uuid.NewString(),\n\t\t\"iat\": now.Unix(),\n\t\t\"exp\": now.Add(a.cfg.RefreshTTL).Unix(),\n\t})\n\tif err != nil {\n\t\treturn authdto.TokenPairResponse{}, err\n\t}\n\n\treturn authdto.TokenPairResponse{\n\t\tAccessToken: accessToken,\n\t\tRefreshToken: refreshToken,\n\t\tTokenType: \"Bearer\",\n\t\tExpiresIn: int(a.cfg.AccessTTL.Seconds()),\n\t}, nil\n}\n```\n"
|
||
}
|
||
]
|
||
}
|