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
|
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
|
// Tables-system intercept: *.table.html is a virtual URL that the
|
||||||
// table handler renders inline, reading rows from a directory of
|
// table handler renders inline, reading rows from a directory of
|
||||||
// *.yaml files declared in the directory's .zddc tables: map.
|
// *.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
|
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, "/") {
|
if !strings.HasSuffix(urlPath, "/") {
|
||||||
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
|
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
|
||||||
return
|
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()
|
root := t.TempDir()
|
||||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||||
"acl:\n permissions:\n \"*@example.com\": rwcda\n")
|
"acl:\n permissions:\n \"*\": rwcda\n")
|
||||||
mustMkdir(t, filepath.Join(root, "ProjectA", "archive", "Acme"))
|
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)
|
idx, err := archive.BuildIndex(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -389,58 +404,46 @@ func TestDispatchMdlRedirect(t *testing.T) {
|
||||||
EmailHeader: "X-Auth-Request-Email",
|
EmailHeader: "X-Auth-Request-Email",
|
||||||
}
|
}
|
||||||
ring := handler.NewLogRing(10)
|
ring := handler.NewLogRing(10)
|
||||||
|
appsSrv, err := setupApps(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setupApps: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
path string
|
path string
|
||||||
wantStatus int
|
wantStatus int
|
||||||
wantLoc string
|
wantNoRedirect bool
|
||||||
}{
|
}{
|
||||||
{
|
{"working no-slash → mdedit", "/Project/working", http.StatusOK, true},
|
||||||
"mdl trailing slash → table",
|
{"working slash → browse", "/Project/working/", http.StatusOK, true},
|
||||||
"/ProjectA/archive/Acme/mdl/",
|
{"staging no-slash → transmittal", "/Project/staging", http.StatusOK, true},
|
||||||
http.StatusFound,
|
{"staging slash → browse", "/Project/staging/", http.StatusOK, true},
|
||||||
"/ProjectA/archive/Acme/mdl.table.html",
|
{"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},
|
||||||
"case-fold MDL trailing slash",
|
{"archive/<party> slash → browse", "/Project/archive/Acme/", http.StatusOK, true},
|
||||||
"/ProjectA/archive/Acme/MDL/",
|
{"archive/<party>/mdl no-slash → tables", "/Project/archive/Acme/mdl", http.StatusOK, true},
|
||||||
http.StatusFound,
|
{"archive/<party>/mdl slash → browse", "/Project/archive/Acme/mdl/", http.StatusOK, true},
|
||||||
"/ProjectA/archive/Acme/mdl.table.html",
|
{"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},
|
||||||
"case-fold ARCHIVE",
|
{"non-canonical slash → browse", "/Project/scratch/", http.StatusOK, true},
|
||||||
"/ProjectA/Archive/Acme/mdl/",
|
{"project root no-slash → 301 to slash", "/Project", http.StatusMovedPermanently, false},
|
||||||
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,
|
|
||||||
"",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
dispatch(cfg, idx, ring, nil, rec, req)
|
dispatch(cfg, idx, ring, appsSrv, rec, req)
|
||||||
if rec.Code != tc.wantStatus {
|
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
|
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