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:
2026-05-29 15:45:12 +00:00
commit 38a415c2ab
33 changed files with 3868 additions and 0 deletions

371
compliance_test.go Normal file
View File

@@ -0,0 +1,371 @@
package core_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
"code.nochebuena.dev/einherjar/contracts/errs"
"code.nochebuena.dev/einherjar/contracts/lifecycle"
"code.nochebuena.dev/einherjar/contracts/logging"
"code.nochebuena.dev/einherjar/core/launcher"
"code.nochebuena.dev/einherjar/core/logz"
"code.nochebuena.dev/einherjar/core/valid"
"code.nochebuena.dev/einherjar/core/xerrors"
)
// ── Compile-time interface satisfaction ──────────────────────────────────────
var _ logging.Logger = logz.New(logz.Config{})
var _ errs.CodedError = (*xerrors.Err)(nil)
var _ errs.ContextualError = (*xerrors.Err)(nil)
var _ valid.Validator = valid.New()
var _ valid.MessageProvider = valid.DefaultMessages
var _ valid.MessageProvider = valid.SpanishMessages
// ── Structural: at most one exported TypeSpec per file ────────────────────────
func TestAtMostOneExportedTypePerFile(t *testing.T) {
fset := token.NewFileSet()
err := filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && (d.Name() == ".git" || d.Name() == "vendor") {
return filepath.SkipDir
}
if !strings.HasSuffix(path, ".go") {
return nil
}
if strings.HasSuffix(path, "_test.go") {
return nil
}
if filepath.Base(path) == "doc.go" {
return nil
}
f, parseErr := parser.ParseFile(fset, path, nil, 0)
if parseErr != nil {
t.Errorf("%s: parse error: %v", path, parseErr)
return nil
}
count := countExportedTypes(f)
if count > 1 {
t.Errorf("%s: has %d exported type declarations; want at most 1", path, count)
}
return nil
})
if err != nil {
t.Fatalf("walk error: %v", err)
}
}
func countExportedTypes(f *ast.File) int {
count := 0
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok {
continue
}
for _, spec := range genDecl.Specs {
ts, ok := spec.(*ast.TypeSpec)
if ok && ts.Name.IsExported() {
count++
}
}
}
return count
}
// ── launcher ─────────────────────────────────────────────────────────────────
type mockComponent struct {
initCalls *[]string
startCalls *[]string
stopCalls *[]string
name string
}
func (m *mockComponent) OnInit() error { *m.initCalls = append(*m.initCalls, m.name); return nil }
func (m *mockComponent) OnStart() error { *m.startCalls = append(*m.startCalls, m.name); return nil }
func (m *mockComponent) OnStop() error { *m.stopCalls = append(*m.stopCalls, m.name); return nil }
var _ lifecycle.Component = (*mockComponent)(nil)
func TestLauncherLifecycleOrder(t *testing.T) {
var inits, starts, stops []string
c1 := &mockComponent{initCalls: &inits, startCalls: &starts, stopCalls: &stops, name: "c1"}
c2 := &mockComponent{initCalls: &inits, startCalls: &starts, stopCalls: &stops, name: "c2"}
logger := logz.New(logz.Config{Writer: &bytes.Buffer{}})
lc := launcher.New(logger)
lc.Append(c1, c2)
done := make(chan error, 1)
go func() { done <- lc.Run() }()
ctx, cancel := context.WithTimeout(context.Background(), 3e9)
defer cancel()
if err := lc.Shutdown(ctx); err != nil {
t.Fatalf("Shutdown: %v", err)
}
if err := <-done; err != nil {
t.Fatalf("Run: %v", err)
}
if len(inits) != 2 || inits[0] != "c1" || inits[1] != "c2" {
t.Errorf("OnInit order: got %v, want [c1 c2]", inits)
}
if len(starts) != 2 || starts[0] != "c1" || starts[1] != "c2" {
t.Errorf("OnStart order: got %v, want [c1 c2]", starts)
}
// OnStop must run in reverse order
if len(stops) != 2 || stops[0] != "c2" || stops[1] != "c1" {
t.Errorf("OnStop order: got %v, want [c2 c1]", stops)
}
}
// ── logz ─────────────────────────────────────────────────────────────────────
func TestLogzErrorEnrichment(t *testing.T) {
var buf bytes.Buffer
logger := logz.New(logz.Config{JSON: true, Writer: &buf})
err := xerrors.New(xerrors.ErrNotFound, "thing missing").
WithContext("id", "abc123")
logger.Error("lookup failed", err)
out := buf.String()
if !strings.Contains(out, "NOT_FOUND") {
t.Errorf("expected error_code=NOT_FOUND in output: %s", out)
}
if !strings.Contains(out, "abc123") {
t.Errorf("expected context field id=abc123 in output: %s", out)
}
}
func TestLogzContextEnrichment(t *testing.T) {
var buf bytes.Buffer
logger := logz.New(logz.Config{JSON: true, Writer: &buf})
ctx := logz.WithRequestID(context.Background(), "req-xyz")
ctx = logz.WithField(ctx, "user_id", "u-001")
logger.WithContext(ctx).Info("handling request")
out := buf.String()
if !strings.Contains(out, "req-xyz") {
t.Errorf("expected request_id=req-xyz in output: %s", out)
}
if !strings.Contains(out, "u-001") {
t.Errorf("expected user_id=u-001 in output: %s", out)
}
}
// ── xerrors ───────────────────────────────────────────────────────────────────
func TestXerrorsCodeRoundtrip(t *testing.T) {
err := xerrors.New(xerrors.ErrNotFound, "user not found")
if err.Code() != xerrors.ErrNotFound {
t.Errorf("Code(): got %s, want %s", err.Code(), xerrors.ErrNotFound)
}
if err.Message() != "user not found" {
t.Errorf("Message(): got %s", err.Message())
}
}
func TestXerrorsContextRoundtrip(t *testing.T) {
err := xerrors.New(xerrors.ErrInvalidInput, "bad").
WithContext("field", "email").
WithContext("rule", "required")
f := err.Fields()
if f["field"] != "email" {
t.Errorf("Fields()[field]: got %v", f["field"])
}
if f["rule"] != "required" {
t.Errorf("Fields()[rule]: got %v", f["rule"])
}
}
func TestXerrorsWrapUnwrap(t *testing.T) {
cause := errors.New("db connection refused")
err := xerrors.Wrap(xerrors.ErrInternal, "query failed", cause)
if !errors.Is(err, cause) {
t.Error("errors.Is should find the wrapped cause")
}
}
func TestXerrorsMarshalJSON(t *testing.T) {
err := xerrors.New(xerrors.ErrNotFound, "thing missing").
WithPlatformCode("THING_NOT_FOUND")
b, jsonErr := json.Marshal(err)
if jsonErr != nil {
t.Fatalf("MarshalJSON: %v", jsonErr)
}
var m map[string]any
if err2 := json.Unmarshal(b, &m); err2 != nil {
t.Fatalf("Unmarshal: %v", err2)
}
if m["code"] != "NOT_FOUND" {
t.Errorf("JSON code: got %v", m["code"])
}
if m["platform_code"] != "THING_NOT_FOUND" {
t.Errorf("JSON platform_code: got %v", m["platform_code"])
}
}
func TestXerrorsConvenienceConstructors(t *testing.T) {
cases := []struct {
err *xerrors.Err
code xerrors.Code
}{
{xerrors.InvalidInput("bad"), xerrors.ErrInvalidInput},
{xerrors.OutOfRange("over"), xerrors.ErrOutOfRange},
{xerrors.Unauthorized("auth"), xerrors.ErrUnauthorized},
{xerrors.PermissionDenied("denied"), xerrors.ErrPermissionDenied},
{xerrors.NotFound("missing"), xerrors.ErrNotFound},
{xerrors.AlreadyExists("dup"), xerrors.ErrAlreadyExists},
{xerrors.Aborted("conflict"), xerrors.ErrAborted},
{xerrors.Gone("deleted"), xerrors.ErrGone},
{xerrors.PreconditionFailed("rule"), xerrors.ErrPreconditionFailed},
{xerrors.RateLimited("slow down"), xerrors.ErrRateLimited},
{xerrors.Cancelled("cancelled"), xerrors.ErrCancelled},
{xerrors.Internal("oops"), xerrors.ErrInternal},
{xerrors.DataLoss("corrupted"), xerrors.ErrDataLoss},
{xerrors.NotImplemented("todo"), xerrors.ErrNotImplemented},
{xerrors.Unavailable("down"), xerrors.ErrUnavailable},
{xerrors.DeadlineExceeded("timeout"), xerrors.ErrDeadlineExceeded},
}
for _, tc := range cases {
if tc.err.Code() != tc.code {
t.Errorf("%s constructor: Code() = %s, want %s", tc.code, tc.err.Code(), tc.code)
}
}
}
// ── valid ─────────────────────────────────────────────────────────────────────
func TestValidValidStruct(t *testing.T) {
type req struct {
Email string `json:"email" validate:"required,email"`
}
v := valid.New()
if err := v.Struct(req{Email: "user@example.com"}); err != nil {
t.Errorf("expected nil for valid struct, got %v", err)
}
}
func TestValidRequiredFieldMissing(t *testing.T) {
type req struct {
Email string `json:"email" validate:"required"`
}
v := valid.New()
err := v.Struct(req{})
if err == nil {
t.Fatal("expected error for missing required field")
}
var xe *xerrors.Err
if !errors.As(err, &xe) {
t.Fatalf("expected *xerrors.Err, got %T", err)
}
if xe.Code() != xerrors.ErrInvalidInput {
t.Errorf("Code(): got %s, want %s", xe.Code(), xerrors.ErrInvalidInput)
}
if xe.Fields()["field"] != "email" {
t.Errorf("Fields()[field]: got %v", xe.Fields()["field"])
}
}
func TestValidNonStructReturnsInternal(t *testing.T) {
v := valid.New()
err := v.Struct("not a struct")
if err == nil {
t.Fatal("expected error for non-struct")
}
var xe *xerrors.Err
if !errors.As(err, &xe) {
t.Fatalf("expected *xerrors.Err, got %T", err)
}
if xe.Code() != xerrors.ErrInternal {
t.Errorf("Code(): got %s, want %s", xe.Code(), xerrors.ErrInternal)
}
}
func TestValidSpanishMessages(t *testing.T) {
type req struct {
Email string `json:"email" validate:"required"`
}
v := valid.New(valid.WithMessageProvider(valid.SpanishMessages))
err := v.Struct(req{})
if err == nil {
t.Fatal("expected error")
}
var xe *xerrors.Err
errors.As(err, &xe)
if !strings.Contains(xe.Message(), "obligatorio") {
t.Errorf("expected Spanish message, got: %s", xe.Message())
}
}
func TestValidCustomValidator(t *testing.T) {
type req struct {
Website string `json:"website" validate:"nohttp"`
}
v := valid.New(
valid.WithCustomValidator("nohttp", func(fl valid.FieldLevel) bool {
return !strings.HasPrefix(fl.Field().String(), "http://")
}),
)
if err := v.Struct(req{Website: "example.com"}); err != nil {
t.Errorf("expected nil for valid value, got %v", err)
}
err := v.Struct(req{Website: "http://example.com"})
if err == nil {
t.Fatal("expected error for http:// value")
}
var xe *xerrors.Err
if !errors.As(err, &xe) {
t.Fatalf("expected *xerrors.Err, got %T", err)
}
if xe.Code() != xerrors.ErrInvalidInput {
t.Errorf("Code(): got %s, want %s", xe.Code(), xerrors.ErrInvalidInput)
}
}
func TestValidOverrideProvider(t *testing.T) {
p := valid.OverrideProvider(
map[string]func(field, param string) string{
"nohttp": func(field, _ string) string {
return "field '" + field + "' must not use http://"
},
},
valid.DefaultMessages,
)
msg := p.Message("website", "nohttp", "")
if msg != "field 'website' must not use http://" {
t.Errorf("custom tag message: got %q", msg)
}
fallback := p.Message("email", "required", "")
if !strings.Contains(fallback, "is required") {
t.Errorf("built-in tag should fall through to DefaultMessages, got: %q", fallback)
}
}