NextMyDay/internal/core/routing/dispatch.go
Claus Lohmar e114a2be61 feat(routing): add the pure routing model (SPEC-002)
Turns a request path plus the two routing fields (`do`/`action`) into a single
Resolution for BOTH doors (D-039): the RESERVED level-1 switch first
(home/access/setup/api/static/media/ws/dev — D-004), then the GUID classifier,
then the registry. Identity and the module registry are injected interfaces, so
the package is PURE — no HTTP stack, no DB (asserted by purity_test.go). All 22
spec rows are covered.
2026-10-14 10:53:48 +00:00

229 lines
7.1 KiB
Go

package routing
// Resolver answers the one identity question the model needs: does a GUID name
// a known identity, and is that identity enrolled? It is INJECTED — the pure
// model never touches a database (SPEC-002 §3.1, §5).
type Resolver interface {
// GUID reports whether guid names a known identity and, if so, whether
// that identity is enrolled (spec §3.5: found · not enrolled -> ENROLL;
// found · enrolled -> continue to the module).
GUID(guid string) (found, enrolled bool)
}
// Module is one entry of the module registry: its PARAM2 name plus its own
// function whitelist and its classifier for unknown values (SPEC-002 §3.7).
// The core never parses a module's internals — it asks the module.
type Module interface {
// Name is the PARAM2 segment — the registry key ("requests", "time", …).
Name() string
// Route resolves a path-mode function (§3.7). function is the PARAM3
// segment ("" means absent/trailing — the module default); tail is the
// remaining segments (PARAM4+). It returns the resolved function name,
// the params that function receives, and ok=false when the value is
// neither whitelisted nor classified (-> 404).
Route(function string, tail Params) (fn string, params Params, ok bool)
// Action resolves an action-mode function — the `action` routing field.
// ok=false -> 404.
Action(action string) (fn string, ok bool)
}
// Registry is the module whitelist: PARAM2 in path mode and `do` in action mode
// (SPEC-002 §3.6/§3.7). SPEC-008 owns the concrete registry; the model depends
// only on this interface.
type Registry interface {
// Module returns the registered module by name. Unknown OR disabled
// modules are reported as not found (-> 404).
Module(name string) (Module, bool)
}
// DefaultModule is the module a resolved GUID falls back to when PARAM2 is
// absent/trailing — the dashboard (SPEC-002 §3.5, §4.4 level 2: "" -> home).
const DefaultModule = "home"
// Dispatcher is the pure model's entry point. It holds the injected identity
// resolver and module registry and resolves a request into a Resolution.
type Dispatcher struct {
Resolver Resolver
Registry Registry
}
// New builds a Dispatcher from the injected dependencies.
func New(res Resolver, reg Registry) *Dispatcher {
return &Dispatcher{Resolver: res, Registry: reg}
}
// Dispatch is the level-1 dispatch (SPEC-002 §3.3, D-039): keyed on the routing
// fields, never the HTTP method.
//
// if do != "" || action != "" -> Handler(do, action, path)
// else -> Router(path)
func (d *Dispatcher) Dispatch(path, do, action string) Resolution {
if do != "" || action != "" {
return d.Handler(do, action, path)
}
return d.Router(path)
}
// Dispatch is a convenience wrapper around (*Dispatcher).Dispatch for a one-shot
// resolution with explicit dependencies.
func Dispatch(res Resolver, reg Registry, path, do, action string) Resolution {
return (&Dispatcher{Resolver: res, Registry: reg}).Dispatch(path, do, action)
}
// Router resolves a path request (SPEC-002 §3.5): the RESERVED switch first (so
// reserved names never touch identity), then the GUID classifier, then the
// module cascade.
func (d *Dispatcher) Router(path string) Resolution {
p := Parse(path)
// Absent PARAM1 ("" / "/") -> the ONE canonical redirect.
if len(p) == 0 {
return Resolution{Kind: KindRedirect, Redirect: "/home"}
}
// An interior empty segment ("//") is a mismatch at any level -> 404.
if hasInteriorEmpty(p) {
return Resolution{Kind: KindNotFound, Workflow: p[0]}
}
// Reserved cases are checked BEFORE the resolver (§3.5).
switch p[0] {
case "setup":
return Resolution{Kind: KindSetup, Workflow: "setup"}
case "home", "access", "api", "static", "media", "ws", "dev":
return reserved(p)
}
return d.guid(p)
}
// Handler resolves an action request (SPEC-002 §3.6). The GUID still rides the
// path (identity is never a form field); `do` selects the module and `action`
// the function — both whitelisted against the registry.
func (d *Dispatcher) Handler(do, action, path string) Resolution {
p := Parse(path)
wf := ""
if len(p) > 0 {
wf = p[0]
}
if d.Registry == nil {
return Resolution{Kind: KindNotFound, Workflow: wf}
}
m, ok := d.Registry.Module(do)
if !ok {
return Resolution{Kind: KindNotFound, Workflow: wf}
}
fn, ok := m.Action(action)
if !ok {
return Resolution{Kind: KindNotFound, Workflow: wf}
}
return Resolution{
Kind: KindAction,
Workflow: wf,
Module: m.Name(),
Function: fn,
Params: tailFrom(p, 2), // the path remainder after the GUID
}
}
// reserved classifies a reserved level-1 workflow. These are core-owned, carry
// no registry module, and hand the raw path remainder to the core workflow.
func reserved(p Params) Resolution {
return Resolution{
Kind: KindRoute,
Workflow: p[0],
Params: tailFrom(p, 1),
}
}
// guid classifies a non-reserved PARAM1 via the injected resolver.
func (d *Dispatcher) guid(p Params) Resolution {
guid := p[0]
if d.Resolver == nil {
return Resolution{Kind: KindNotFound, Workflow: guid}
}
found, enrolled := d.Resolver.GUID(guid)
if !found {
return Resolution{Kind: KindNotFound, Workflow: guid}
}
if !enrolled {
return Resolution{Kind: KindEnroll, Workflow: guid}
}
return d.module(guid, p)
}
// module resolves PARAM2 (the module) and PARAM3+ (the module's own cascade)
// for a resolved, enrolled GUID (SPEC-002 §3.5/§3.7).
func (d *Dispatcher) module(guid string, p Params) Resolution {
// PARAM2 — the module (absent/trailing empty -> the dashboard default).
name, absent := segment(p, 1)
if absent {
name = DefaultModule
}
if d.Registry == nil {
return Resolution{Kind: KindNotFound, Workflow: guid}
}
m, ok := d.Registry.Module(name)
if !ok {
return Resolution{Kind: KindNotFound, Workflow: guid}
}
// PARAM3 — the function (absent/trailing empty -> the module default).
fn, absent := segment(p, 2)
if absent {
fn = ""
}
resolved, params, ok := m.Route(fn, tailFrom(p, 3))
if !ok {
return Resolution{Kind: KindNotFound, Workflow: guid}
}
return Resolution{
Kind: KindRoute,
Workflow: guid,
Module: m.Name(),
Function: resolved,
Params: params,
}
}
// segment applies the presence rules for p[i] (SPEC-002 §3.4). Interior empties
// were already rejected by hasInteriorEmpty, so a "" reaching here is always a
// trailing slash — which is "absent". Callers substitute their default.
func segment(p Params, i int) (value string, absent bool) {
if i >= len(p) || p[i] == "" {
return "", true
}
return p[i], false
}
// hasInteriorEmpty reports whether any non-final segment is empty ("//").
func hasInteriorEmpty(p Params) bool {
for i := 0; i < len(p)-1; i++ {
if p[i] == "" {
return true
}
}
return false
}
// tailFrom returns the remainder p[i:] for a resolved function (SPEC-002 §3.4).
// A trailing empty — a trailing slash — is "absent" and is dropped, so the
// function receives no phantom empty param. Interior empties never reach here:
// hasInteriorEmpty already rejected them.
func tailFrom(p Params, i int) Params {
if i >= len(p) {
return nil
}
rest := p[i:]
if n := len(rest); n > 0 && rest[n-1] == "" {
rest = rest[:n-1]
}
return rest
}