feat(core): initial implementation — launcher, logz, xerrors, valid
Introduces `code.nochebuena.dev/einherjar/core` — the foundational implementation module of the Einherjar framework. Provides four sub-packages that together cover every service's baseline needs: lifecycle management, structured logging, typed errors, and struct validation. - launcher: Launcher interface — three-phase managed lifecycle (OnInit → BeforeStart hooks → OnStart → OS signal wait → OnStop in reverse). Accepts lifecycle.Component and logging.Logger from contracts. Prints an ASCII art banner at startup (EINHERJAR_BANNER=off to suppress). Banner includes core version via runtime/debug.ReadBuildInfo() and a loaded-module list for every registered component that implements observability.Identifiable. Config struct with EINHERJAR_COMPONENT_STOP_TIMEOUT env tag (caarlos0/env syntax, default 15s). - logz: Logger implementation backed by log/slog. Returns contracts/logging.Logger. Detects errs.CodedError and errs.ContextualError (from contracts/errs) to enrich log records automatically — replaces the private duck-typed bridge from micro-lib. Context helpers: WithRequestID, WithField, WithFields, GetRequestID. Config struct with EINHERJAR_LOG_LEVEL (default INFO) and EINHERJAR_LOG_JSON (default false) env tags (caarlos0/env syntax); programmatic-only fields StaticArgs and Writer carry no tags. - xerrors: Typed error codes with context enrichment. Complete gRPC canonical set (16 codes) plus HTTP 410 ErrGone. Adds ErrOutOfRange, ErrAborted, ErrDataLoss over micro-lib. One convenience constructor per code. *Err declares compile-time satisfaction of errs.CodedError and errs.ContextualError. - valid: Struct validation wrapping go-playground/validator/v10. Validator interface + MessageProvider interface with full built-in tag coverage (~150 tags) in both DefaultMessages (English) and SpanishMessages (Spanish). Backend fully hidden; returns *xerrors.Err with ErrInvalidInput or ErrInternal. FieldLevel interface abstracts the backend's field-level access for custom validators. WithCustomValidator registers custom validation tags at construction time; OverrideProvider chains a tag→handler map with a fallback MessageProvider for custom tag messages without re-implementing built-ins. Compliance test enforces CT-6 (at most one exported TypeSpec per file via AST) and verifies behavioural correctness of all four sub-packages, including custom validator registration and OverrideProvider composition. Compile-time var _ assertions prove interface satisfaction. docs: ADR-001 (core module composition), ADR-002 (logz contracts/errs adoption), ADR-003 (Config naming convention and caarlos0/env tag standard)
This commit is contained in:
245
README.md
Normal file
245
README.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# einherjar/core
|
||||
|
||||
[](https://code.nochebuena.dev/einherjar/core)
|
||||
[](LICENSE)
|
||||
[](https://go.dev)
|
||||
|
||||
> The chosen warriors do not choose their weapons. They forge them.
|
||||
|
||||
`code.nochebuena.dev/einherjar/core` is the foundational implementation module of the
|
||||
Einherjar framework. It sits directly above `contracts` in the dependency graph and
|
||||
provides the concrete tools every service needs before anything else can start:
|
||||
a lifecycle runner, a structured logger, typed errors, and struct validation.
|
||||
|
||||
---
|
||||
|
||||
## What Is Einherjar?
|
||||
|
||||
In Norse mythology, the Einherjar are the chosen warriors of Valhalla — selected not
|
||||
for glory, but to be ready for what comes after. They train. They prepare. They build
|
||||
the capability that others will rely on.
|
||||
|
||||
This framework is named for that purpose. Every module is a piece of that preparation:
|
||||
built carefully, documented for those who were never in the room, and designed to hold
|
||||
under pressure.
|
||||
|
||||
---
|
||||
|
||||
## Sub-packages
|
||||
|
||||
| Package | Import path | Purpose |
|
||||
|---|---|---|
|
||||
| `launcher` | `.../core/launcher` | Application lifecycle — init, start, shutdown |
|
||||
| `logz` | `.../core/logz` | Structured, leveled logging via `log/slog` |
|
||||
| `xerrors` | `.../core/xerrors` | Typed error codes with context enrichment |
|
||||
| `valid` | `.../core/valid` | Struct validation with pluggable i18n messages |
|
||||
|
||||
All four are in one module because they ship together in every Einherjar service.
|
||||
See [ADR-001](docs/adr/ADR-001-core-module-composition.md).
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Launcher
|
||||
|
||||
```go
|
||||
import (
|
||||
"code.nochebuena.dev/einherjar/core/launcher"
|
||||
"code.nochebuena.dev/einherjar/core/logz"
|
||||
)
|
||||
|
||||
logger := logz.New(logz.Config{JSON: true, StaticArgs: []any{"service", "api"}})
|
||||
|
||||
lc := launcher.New(logger)
|
||||
lc.Append(db, cache, server)
|
||||
lc.BeforeStart(func() error {
|
||||
return server.RegisterRoutes(db, cache)
|
||||
})
|
||||
|
||||
if err := lc.Run(); err != nil {
|
||||
logger.Error("launcher failed", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables (uses `caarlos0/env` tag syntax — application supplies the loader):
|
||||
|
||||
| Variable | Default | Effect |
|
||||
|---|---|---|
|
||||
| `EINHERJAR_BANNER` | _(on)_ | Set to `off` or `false` to suppress the startup banner |
|
||||
| `EINHERJAR_COMPONENT_STOP_TIMEOUT` | `15s` | Maximum time per component `OnStop` |
|
||||
|
||||
### Logger
|
||||
|
||||
```go
|
||||
import "code.nochebuena.dev/einherjar/core/logz"
|
||||
|
||||
logger := logz.New(logz.Config{Level: slog.LevelDebug, JSON: true})
|
||||
|
||||
// Attach request context in middleware
|
||||
ctx = logz.WithRequestID(ctx, requestID)
|
||||
ctx = logz.WithField(ctx, "user_id", userID)
|
||||
|
||||
// Enrich logger from context in handlers
|
||||
reqLogger := logger.WithContext(ctx)
|
||||
reqLogger.Info("handling request", "path", r.URL.Path)
|
||||
|
||||
// Error enrichment is automatic — no extra code needed
|
||||
reqLogger.Error("query failed", err) // appends error_code and context fields
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Variable | Default | Effect |
|
||||
|---|---|---|
|
||||
| `EINHERJAR_LOG_LEVEL` | `INFO` | Minimum log level (`DEBUG`, `INFO`, `WARN`, `ERROR`) |
|
||||
| `EINHERJAR_LOG_JSON` | `false` | JSON output when `true` |
|
||||
|
||||
### Errors
|
||||
|
||||
```go
|
||||
import "code.nochebuena.dev/einherjar/core/xerrors"
|
||||
|
||||
// Named constructors for common cases
|
||||
err := xerrors.NotFound("user %s not found", userID)
|
||||
err := xerrors.InvalidInput("email is required")
|
||||
err := xerrors.Aborted("order modified by another session")
|
||||
|
||||
// Builder pattern for structured context
|
||||
err := xerrors.New(xerrors.ErrInvalidInput, "validation failed").
|
||||
WithContext("field", "email").
|
||||
WithContext("rule", "required").
|
||||
WithError(cause)
|
||||
|
||||
// Inspecting errors
|
||||
var e *xerrors.Err
|
||||
if errors.As(err, &e) {
|
||||
switch e.Code() {
|
||||
case xerrors.ErrNotFound: // HTTP 404
|
||||
case xerrors.ErrUnauthorized: // HTTP 401
|
||||
case xerrors.ErrInvalidInput: // HTTP 400
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
```go
|
||||
import "code.nochebuena.dev/einherjar/core/valid"
|
||||
|
||||
type CreateUserReq struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Age int `json:"age" validate:"gte=18"`
|
||||
}
|
||||
|
||||
v := valid.New() // English messages
|
||||
// v := valid.New(valid.WithMessageProvider(valid.SpanishMessages))
|
||||
|
||||
if err := v.Struct(req); err != nil {
|
||||
var xe *xerrors.Err
|
||||
errors.As(err, &xe)
|
||||
// xe.Code() == xerrors.ErrInvalidInput
|
||||
// xe.Fields() == {"field": "email", "tag": "required"}
|
||||
// xe.Message() == "field 'email' is required"
|
||||
}
|
||||
```
|
||||
|
||||
All go-playground/validator built-in tags have specific messages in both `DefaultMessages`
|
||||
and `SpanishMessages`. The generic fallback only fires for unknown tags.
|
||||
|
||||
#### Custom validators
|
||||
|
||||
```go
|
||||
import "strings"
|
||||
|
||||
v := valid.New(
|
||||
valid.WithCustomValidator("nohttp", func(fl valid.FieldLevel) bool {
|
||||
return !strings.HasPrefix(fl.Field().String(), "http://")
|
||||
}),
|
||||
valid.WithMessageProvider(valid.OverrideProvider(
|
||||
map[string]func(field, param string) string{
|
||||
"nohttp": func(field, _ string) string {
|
||||
return fmt.Sprintf("field '%s' must not use http://", field)
|
||||
},
|
||||
},
|
||||
valid.DefaultMessages,
|
||||
)),
|
||||
)
|
||||
```
|
||||
|
||||
`OverrideProvider` chains a tag→message map with a fallback provider, so custom tag
|
||||
messages are handled without re-implementing all built-ins.
|
||||
|
||||
---
|
||||
|
||||
## Error Codes
|
||||
|
||||
`xerrors` provides the full gRPC canonical error code set plus HTTP 410 (`ErrGone`):
|
||||
|
||||
| Constant | Wire value | HTTP | When to use |
|
||||
|---|---|---|---|
|
||||
| `ErrInvalidInput` | `INVALID_ARGUMENT` | 400 | Malformed or invalid request data |
|
||||
| `ErrOutOfRange` | `OUT_OF_RANGE` | 400 | Valid value but outside accepted bounds |
|
||||
| `ErrUnauthorized` | `UNAUTHENTICATED` | 401 | Missing or invalid credentials |
|
||||
| `ErrPermissionDenied` | `PERMISSION_DENIED` | 403 | Authenticated but not authorised |
|
||||
| `ErrNotFound` | `NOT_FOUND` | 404 | Resource does not exist |
|
||||
| `ErrAlreadyExists` | `ALREADY_EXISTS` | 409 | Creation conflict (duplicate) |
|
||||
| `ErrAborted` | `ABORTED` | 409 | Concurrent modification; retry may succeed |
|
||||
| `ErrGone` | `GONE` | 410 | Resource permanently deleted |
|
||||
| `ErrPreconditionFailed` | `FAILED_PRECONDITION` | 412 | Business rule blocks the operation |
|
||||
| `ErrRateLimited` | `RESOURCE_EXHAUSTED` | 429 | Rate limit or quota exceeded |
|
||||
| `ErrCancelled` | `CANCELLED` | 499 | Request cancelled by the caller |
|
||||
| `ErrInternal` | `INTERNAL` | 500 | Unexpected server-side failure |
|
||||
| `ErrDataLoss` | `DATA_LOSS` | 500 | Unrecoverable data corruption |
|
||||
| `ErrNotImplemented` | `UNIMPLEMENTED` | 501 | Operation not implemented |
|
||||
| `ErrUnavailable` | `UNAVAILABLE` | 503 | Service temporarily unavailable |
|
||||
| `ErrDeadlineExceeded` | `DEADLINE_EXCEEDED` | 504 | Operation timed out |
|
||||
|
||||
Wire values are stable across versions and safe to persist, send over the network,
|
||||
or switch on in client code.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Rules
|
||||
|
||||
```
|
||||
contracts (zero dependencies)
|
||||
↑
|
||||
core (depends on contracts only)
|
||||
↑
|
||||
starters (depend on core + contracts)
|
||||
↑
|
||||
your app
|
||||
```
|
||||
|
||||
`core` imports `contracts`. Nothing above `core` in this chain may import `core`
|
||||
directly — they depend on starters, which compose core's sub-packages behind
|
||||
framework-specific APIs.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd core/
|
||||
go build ./... # must compile clean
|
||||
go vet ./... # no warnings
|
||||
go test ./... # structural + behavioural compliance passes
|
||||
gofmt -l . # no output
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
| ADR | Title |
|
||||
|---|---|
|
||||
| [ADR-001](docs/adr/ADR-001-core-module-composition.md) | Four sub-packages in one Go module |
|
||||
| [ADR-002](docs/adr/ADR-002-logz-contracts-errs.md) | logz adopts contracts/errs instead of private duck typing |
|
||||
| [ADR-003](docs/adr/ADR-003-config-env-tags.md) | Config naming convention and caarlos0/env tag standard |
|
||||
|
||||
---
|
||||
|
||||
> *They were not chosen because they were the strongest.*
|
||||
> *They were chosen because they understood what they were building toward.*
|
||||
Reference in New Issue
Block a user