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.
229 lines
7.1 KiB
Go
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
|
|
}
|