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.
159 lines
4.8 KiB
Go
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
|
|
}
|