NextMyDay/internal/core/routing/routing.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

159 lines
4.8 KiB
Go

// Package routing implements MyDay's pure routing model.
//
// It turns a request path plus the two routing fields (`do` / `action`) into a
// single Resolution for BOTH doors — ROUTER (path) and HANDLER (actions), per
// D-039 and SPEC-002.
//
// The model is deliberately PURE:
//
// - it imports no HTTP stack and no database driver;
// - it never touches storage — identity (GUID -> found/enrolled) and the
// module registry are INJECTED interfaces (§3.1);
// - so the whole cascade is exhaustively unit-testable in isolation (§5).
//
// Governing decisions: D-001 · D-004 · D-017 · D-018 · D-032 · D-039.
// Spec: docs/specs/spec-002-routing-model.md.
package routing
import (
"fmt"
"strings"
)
// Params is the exploded path: the path split on "/" with EMPTY SEGMENTS
// PRESERVED (SPEC-002 §3.4). An empty segment is meaningful — a trailing empty
// is a trailing slash (→ default), while an interior empty ("//") is a mismatch
// (→ 404).
type Params []string
// Parse strips a single leading "/", splits on "/", and PRESERVES empty
// segments (SPEC-002 §3.4):
//
// "/" -> {}
// "/home" -> {"home"}
// "/home/" -> {"home", ""}
// "/{guid}/request/pto" -> {"{guid}", "request", "pto"}
// "/a//b" -> {"a", "", "b"}
func Parse(path string) Params {
trimmed := strings.TrimPrefix(path, "/")
if trimmed == "" {
return Params{}
}
return Params(strings.Split(trimmed, "/"))
}
// Kind is the classification of a Resolution (SPEC-002 §3.2).
type Kind int
const (
// KindRoute is a path-mode resolution -> (module, function, params).
KindRoute Kind = iota
// KindAction is an action-mode resolution, reached via `do`/`action`.
KindAction
// KindRedirect is the ONE canonical redirect ("/" -> "/home").
KindRedirect
// KindNotFound is a 404.
KindNotFound
// KindEnroll is the enrollment screen (GUID found but not enrolled).
KindEnroll
// KindLogin is the login screen. The PURE model never returns this: it
// returns KindRoute and the door upgrades it when no session exists
// (SPEC-002 §6).
KindLogin
// KindSetup is the setup wizard (setup mode only).
KindSetup
)
// String renders a Kind for logs and test failures.
func (k Kind) String() string {
switch k {
case KindRoute:
return "Route"
case KindAction:
return "Action"
case KindRedirect:
return "Redirect"
case KindNotFound:
return "NotFound"
case KindEnroll:
return "Enroll"
case KindLogin:
return "Login"
case KindSetup:
return "Setup"
default:
return fmt.Sprintf("Kind(%d)", int(k))
}
}
// Resolution is the single outcome of the model (SPEC-002 §3.2). One request
// resolves to one Resolution; the door then gates and dispatches it.
type Resolution struct {
// Kind classifies the outcome.
Kind Kind
// Workflow is the PARAM1 classification: a reserved workflow ("home",
// "access", "setup", "api", …) or the GUID itself; "" for the redirect.
Workflow string
// Module is the resolved registry module (PARAM2 / `do`). Empty for
// reserved workflows — those are core-owned and carry no module.
Module string
// Function is the resolved module function (PARAM3 / `action`).
Function string
// Params is the remainder handed to the function.
Params Params
// Redirect is the target path when Kind == KindRedirect.
Redirect string
}
// ReservedWorkflows is the ONE source for the reserved level-1 names (SPEC-002
// §4, D-004). A GUID may never equal one of these; validate on create AND
// update with ValidateWorkflowName.
//
// NOTE: kept as an exported slice because the spec declares it as a constant
// source of truth; treat it as read-only. Use IsReservedWorkflow for checks.
var ReservedWorkflows = []string{
"home",
"access",
"setup",
"api",
"static",
"media",
"ws",
"dev",
}
// IsReservedWorkflow reports whether name is a reserved level-1 workflow
// (exact, case-sensitive match — SPEC-002 §4).
func IsReservedWorkflow(name string) bool {
for _, w := range ReservedWorkflows {
if w == name {
return true
}
}
return false
}
// ValidateWorkflowName rejects a name that collides with a reserved workflow.
// It is the single guard used when a GUID (or any workflow name) is created or
// updated (SPEC-002 §4, test row 22).
func ValidateWorkflowName(name string) error {
if IsReservedWorkflow(name) {
return fmt.Errorf("routing: %q is a reserved workflow name and cannot be a GUID", name)
}
return nil
}
// IsNumeric reports whether s is a non-empty ASCII digit string. It is the
// built-in classifier modules use for unknown path values such as numeric ids
// (SPEC-002 §3.7); e.g. `/{GUID}/requests/42` -> function "detail", params {42}.
func IsNumeric(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}