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:
371
compliance_test.go
Normal file
371
compliance_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user