feat(dispatch): trailing slash → browse, no slash → canonical default tool
URL convention for directories under a project:
- <dir>/ (with trailing slash) → browse (the directory view; same
behaviour as today)
- <dir> (without trailing slash) → the canonical default tool for
that directory's context, served
inline (no 301 hop)
Tool mapping via the new apps.DefaultAppAt(root, dir):
- working/... → mdedit
- staging/... → transmittal
- archive/ → archive
- archive/<party>/ → archive
- archive/<party>/incoming|received|issued/... → archive
- archive/<party>/mdl/... → tables (the per-party MDL grid editor)
Directories outside the canonical layout (project root, scratch
folders) keep the legacy 301-to-trailing-slash redirect since no
default tool fits.
This generalises and replaces the bespoke
"GET archive/<party>/mdl/ → 302 mdl.table.html" redirect added in PR4.
The new dispatcher rule serves the table app inline at the bare-mdl
URL by routing through RecognizeTableRequest with the canonical
.table.html suffix appended; relative fetches resolve identically
because both URLs share the same parent directory.
Tests: TestDefaultAppAt covers all canonical positions plus
case-fold and out-of-tree edges. TestDispatchSlashRouting (replacing
the now-obsolete TestDispatchMdlRedirect) verifies the slash-vs-no-
slash distinction at every canonical folder + non-canonical
fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dc7bf8ab04
commit
f7958d7b22
4 changed files with 177 additions and 61 deletions
|
|
@ -529,23 +529,6 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
return
|
||||
}
|
||||
|
||||
// MDL convenience redirect: GET archive/<party>/mdl/ → mdl.table.html.
|
||||
// The table app is the canonical view for the per-party Master
|
||||
// Deliverables List. Direct navigation to the data folder lands on
|
||||
// the grid editor; clients that want the raw row listing can still
|
||||
// hit archive/<party>/mdl/ via the file API or with an explicit
|
||||
// trailing-segment beyond mdl/.
|
||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && len(segments) == 4 {
|
||||
if strings.EqualFold(segments[1], "archive") && strings.EqualFold(segments[3], "mdl") {
|
||||
target := "/" + segments[0] + "/" + segments[1] + "/" + segments[2] + "/mdl.table.html"
|
||||
if r.URL.RawQuery != "" {
|
||||
target += "?" + r.URL.RawQuery
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Tables-system intercept: *.table.html is a virtual URL that the
|
||||
// table handler renders inline, reading rows from a directory of
|
||||
// *.yaml files declared in the directory's .zddc tables: map.
|
||||
|
|
@ -680,6 +663,37 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
return
|
||||
}
|
||||
}
|
||||
// URL convention: trailing slash → browse (handled by
|
||||
// ServeDirectory, which serves browse.html for HTML requests
|
||||
// and JSON for application/json). No trailing slash → the
|
||||
// canonical default tool for this directory's context, if any
|
||||
// (mdedit under working/, transmittal under staging/, archive
|
||||
// under archive/, tables under archive/<party>/mdl/). When no
|
||||
// default applies, fall back to the historical redirect-to-
|
||||
// trailing-slash behaviour.
|
||||
if !strings.HasSuffix(urlPath, "/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) && !isRoot {
|
||||
switch apps.DefaultAppAt(cfg.Root, absPath) {
|
||||
case "tables":
|
||||
// Tables aren't an apps-subsystem app — the table
|
||||
// handler responds to <dir>/<name>.table.html. Serve
|
||||
// the equivalent table view inline at the bare-mdl
|
||||
// URL by routing through the handler with the
|
||||
// canonical .table.html name appended.
|
||||
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, urlPath+".table.html"); tr != nil {
|
||||
handler.ServeTable(cfg, tr, w, r)
|
||||
return
|
||||
}
|
||||
case "archive", "transmittal", "mdedit":
|
||||
if appsSrv != nil {
|
||||
app := apps.DefaultAppAt(cfg.Root, absPath)
|
||||
if apps.AppAvailableAt(cfg.Root, absPath, app) {
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
|
||||
appsSrv.Serve(w, r, app, chain, absPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(urlPath, "/") {
|
||||
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -373,11 +373,26 @@ func TestDispatchArchiveRedirect(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDispatchMdlRedirect(t *testing.T) {
|
||||
func TestDispatchSlashRouting(t *testing.T) {
|
||||
// Convention: <dir>/ → browse (directory view); <dir> → the canonical
|
||||
// default tool for the directory (mdedit under working/, transmittal
|
||||
// under staging/, archive under archive/, tables under
|
||||
// archive/<party>/mdl/). Without a default app, no-slash falls
|
||||
// through to the legacy 301-to-trailing-slash redirect.
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n permissions:\n \"*@example.com\": rwcda\n")
|
||||
mustMkdir(t, filepath.Join(root, "ProjectA", "archive", "Acme"))
|
||||
"acl:\n permissions:\n \"*\": rwcda\n")
|
||||
for _, sub := range []string{
|
||||
"Project/working",
|
||||
"Project/staging",
|
||||
"Project/archive",
|
||||
"Project/archive/Acme",
|
||||
"Project/archive/Acme/incoming",
|
||||
"Project/archive/Acme/mdl",
|
||||
"Project/scratch",
|
||||
} {
|
||||
mustMkdir(t, filepath.Join(root, sub))
|
||||
}
|
||||
|
||||
idx, err := archive.BuildIndex(root)
|
||||
if err != nil {
|
||||
|
|
@ -389,58 +404,46 @@ func TestDispatchMdlRedirect(t *testing.T) {
|
|||
EmailHeader: "X-Auth-Request-Email",
|
||||
}
|
||||
ring := handler.NewLogRing(10)
|
||||
appsSrv, err := setupApps(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("setupApps: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
wantLoc string
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
wantNoRedirect bool
|
||||
}{
|
||||
{
|
||||
"mdl trailing slash → table",
|
||||
"/ProjectA/archive/Acme/mdl/",
|
||||
http.StatusFound,
|
||||
"/ProjectA/archive/Acme/mdl.table.html",
|
||||
},
|
||||
{
|
||||
"case-fold MDL trailing slash",
|
||||
"/ProjectA/archive/Acme/MDL/",
|
||||
http.StatusFound,
|
||||
"/ProjectA/archive/Acme/mdl.table.html",
|
||||
},
|
||||
{
|
||||
"case-fold ARCHIVE",
|
||||
"/ProjectA/Archive/Acme/mdl/",
|
||||
http.StatusFound,
|
||||
"/ProjectA/Archive/Acme/mdl.table.html",
|
||||
},
|
||||
{
|
||||
"deeper than party-level mdl is NOT redirected",
|
||||
"/ProjectA/archive/Acme/incoming/mdl/",
|
||||
// Falls through to static-file pipeline; no folder exists there → 404.
|
||||
http.StatusNotFound,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"working/mdl is NOT redirected (not under archive)",
|
||||
"/ProjectA/working/mdl/",
|
||||
http.StatusNotFound,
|
||||
"",
|
||||
},
|
||||
{"working no-slash → mdedit", "/Project/working", http.StatusOK, true},
|
||||
{"working slash → browse", "/Project/working/", http.StatusOK, true},
|
||||
{"staging no-slash → transmittal", "/Project/staging", http.StatusOK, true},
|
||||
{"staging slash → browse", "/Project/staging/", http.StatusOK, true},
|
||||
{"archive no-slash → archive", "/Project/archive", http.StatusOK, true},
|
||||
{"archive slash → browse", "/Project/archive/", http.StatusOK, true},
|
||||
{"archive/<party> no-slash → archive", "/Project/archive/Acme", http.StatusOK, true},
|
||||
{"archive/<party> slash → browse", "/Project/archive/Acme/", http.StatusOK, true},
|
||||
{"archive/<party>/mdl no-slash → tables", "/Project/archive/Acme/mdl", http.StatusOK, true},
|
||||
{"archive/<party>/mdl slash → browse", "/Project/archive/Acme/mdl/", http.StatusOK, true},
|
||||
{"archive/<party>/incoming no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true},
|
||||
{"archive/<party>/incoming slash → browse", "/Project/archive/Acme/incoming/", http.StatusOK, true},
|
||||
{"non-canonical no-slash → 301 to slash", "/Project/scratch", http.StatusMovedPermanently, false},
|
||||
{"non-canonical slash → browse", "/Project/scratch/", http.StatusOK, true},
|
||||
{"project root no-slash → 301 to slash", "/Project", http.StatusMovedPermanently, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, rec, req)
|
||||
dispatch(cfg, idx, ring, appsSrv, rec, req)
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String())
|
||||
t.Fatalf("path=%q status=%d, want %d; body=%s",
|
||||
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
|
||||
}
|
||||
if tc.wantLoc != "" {
|
||||
if got := rec.Header().Get("Location"); got != tc.wantLoc {
|
||||
t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc)
|
||||
}
|
||||
if tc.wantNoRedirect && rec.Code >= 300 && rec.Code < 400 {
|
||||
t.Errorf("path=%q unexpected redirect to %q",
|
||||
tc.path, rec.Header().Get("Location"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,3 +68,56 @@ func inAncestorWithName(root, requestDir string, names ...string) bool {
|
|||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DefaultAppAt returns the canonical default tool name for requestDir,
|
||||
// or "" if no specific tool fits. Used by the dispatcher to decide
|
||||
// which app to serve at a directory URL with no trailing slash —
|
||||
// trailing-slash URLs serve the browse app for any directory.
|
||||
//
|
||||
// Rules (case-insensitive on canonical folder names):
|
||||
//
|
||||
// - <project>/archive/<party>/mdl/... → "tables"
|
||||
// - <project>/archive/ → "archive"
|
||||
// - <project>/archive/<party>/... → "archive"
|
||||
// - <project>/staging/... → "transmittal"
|
||||
// - <project>/working/... → "mdedit"
|
||||
// - any other directory → "" (no default)
|
||||
//
|
||||
// The mdl rule wins over the broader archive rule because the table
|
||||
// editor is a more specific surface for browsing planned deliverables
|
||||
// than the archive index.
|
||||
//
|
||||
// requestDir and root are absolute filesystem paths; requestDir must
|
||||
// be under root (otherwise "" is returned).
|
||||
func DefaultAppAt(root, requestDir string) string {
|
||||
root = filepath.Clean(root)
|
||||
requestDir = filepath.Clean(requestDir)
|
||||
if requestDir == root {
|
||||
return ""
|
||||
}
|
||||
rel, err := filepath.Rel(root, requestDir)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(rel, string(filepath.Separator))
|
||||
if len(parts) < 2 {
|
||||
// Project root itself — no default tool.
|
||||
return ""
|
||||
}
|
||||
// The project segment is parts[0]; canonical folder is parts[1].
|
||||
canonical := strings.ToLower(parts[1])
|
||||
switch canonical {
|
||||
case "archive":
|
||||
// Inside archive/. Check for the mdl sub-case at depth 4
|
||||
// (parts: project, archive, party, mdl).
|
||||
if len(parts) >= 4 && strings.EqualFold(parts[3], "mdl") {
|
||||
return "tables"
|
||||
}
|
||||
return "archive"
|
||||
case "staging":
|
||||
return "transmittal"
|
||||
case "working":
|
||||
return "mdedit"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,3 +67,49 @@ func TestAppAvailableAt(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAppAt(t *testing.T) {
|
||||
root := "/srv/zddc"
|
||||
cases := []struct {
|
||||
dir string
|
||||
want string
|
||||
}{
|
||||
// At the deployment root itself, no default tool — landing handles
|
||||
// the project picker via a separate path.
|
||||
{root, ""},
|
||||
// Bare project root: no default. Trailing-slash URL serves browse;
|
||||
// no-slash falls through to the redirect.
|
||||
{root + "/Project-A", ""},
|
||||
// Canonical project-root folders.
|
||||
{root + "/Project-A/working", "mdedit"},
|
||||
{root + "/Project-A/working/alice@example.com", "mdedit"},
|
||||
{root + "/Project-A/working/2026-06-15_x (DFT) - y", "mdedit"},
|
||||
{root + "/Project-A/staging", "transmittal"},
|
||||
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"},
|
||||
// archive: at the archive root, party folders, and per-party
|
||||
// subfolders (incoming/received/issued).
|
||||
{root + "/Project-A/archive", "archive"},
|
||||
{root + "/Project-A/archive/Acme", "archive"},
|
||||
{root + "/Project-A/archive/Acme/incoming", "archive"},
|
||||
{root + "/Project-A/archive/Acme/issued", "archive"},
|
||||
{root + "/Project-A/archive/Acme/received", "archive"},
|
||||
// mdl wins over the broader archive rule.
|
||||
{root + "/Project-A/archive/Acme/mdl", "tables"},
|
||||
{root + "/Project-A/archive/Acme/mdl/anything-deeper", "tables"},
|
||||
// reviewing/ is virtual — no default tool wired here yet.
|
||||
{root + "/Project-A/reviewing", ""},
|
||||
// Random non-canonical folder names → no default.
|
||||
{root + "/Project-A/scratch", ""},
|
||||
// Case-fold on canonical names.
|
||||
{root + "/Project-A/Working", "mdedit"},
|
||||
{root + "/Project-A/STAGING", "transmittal"},
|
||||
{root + "/Project-A/Archive/Acme/MDL", "tables"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.dir, func(t *testing.T) {
|
||||
if got := DefaultAppAt(root, tc.dir); got != tc.want {
|
||||
t.Errorf("DefaultAppAt(%q) = %q, want %q", tc.dir, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue