Skip to content

Commit 9d6d33e

Browse files
committed
feat(api): add WithNoRoute(handler) Option for SPA-host fallback
The Engine builds gin's NoRoute hook from a new noRouteHandler field, set via WithNoRoute(h gin.HandlerFunc). Mounted after every explicit route + group so it never shadows real handlers; nil preserves gin's default 404 behaviour (back-compat with pre-option callers). Use case: lthn/desktop and lthn.ai-hosted Engines both serve an SPA whose client-router owns the URL space. Today they wrap the gin engine via Handler() and call NoRoute() directly, which means they bypass any auth/SSRF/cache middleware composed into the Engine. With WithNoRoute the SPA fallback becomes part of the canonical Engine build and inherits the middleware chain. go: - pkg/api: Engine gains `noRouteHandler gin.HandlerFunc` - new option WithNoRoute(h) sets it - build() registers it via r.NoRoute(...) when non-nil - TestWithNoRoute_{Good,Bad,Ugly} AX-7 triplet - ExampleWithNoRoute() showing the SPA-host pattern Also dedupes accidentally-duplicated TestSDKGenerator_Generate_ PackageName{Accepted_Good,Rejected_Bad} from the PR #3 merge (two identical copies of the same two tests at 199-269 and 270-339 caused the test binary to fail compile).
1 parent 7ff2b4f commit 9d6d33e

5 files changed

Lines changed: 140 additions & 70 deletions

File tree

go/api.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ type Engine struct {
8383
i18nConfig I18nConfig
8484
openAPISpecEnabled bool
8585
openAPISpecPath string
86+
// noRouteHandler is the SPA / fallback handler invoked when no
87+
// registered route matches the request. Set via WithNoRoute; nil
88+
// means gin returns 404 with its default body.
89+
noRouteHandler gin.HandlerFunc
8690
}
8791

8892
// New creates an Engine with the given options.
@@ -387,6 +391,13 @@ func (e *Engine) build() *gin.Engine {
387391
r.GET("/debug/vars", expvar.Handler())
388392
}
389393

394+
// SPA / fallback handler. Registered last so all explicit routes
395+
// take precedence — Gin invokes NoRoute only when no other handler
396+
// matched the request path + method.
397+
if e.noRouteHandler != nil {
398+
r.NoRoute(e.noRouteHandler)
399+
}
400+
390401
return r
391402
}
392403

go/codegen_test.go

Lines changed: 0 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -267,73 +267,3 @@ func TestSDKGenerator_Generate_PackageNameAccepted_Good(t *testing.T) {
267267
}
268268
}
269269

270-
// TestSDKGenerator_Generate_PackageNameRejected_Bad verifies the regex-validation
271-
// hardening from Mantis #322 — PackageName containing flag-injection characters
272-
// is rejected before exec.CommandContext is reached.
273-
func TestSDKGenerator_Generate_PackageNameRejected_Bad(t *testing.T) {
274-
tmp := t.TempDir()
275-
specPath := filepath.Join(tmp, "spec.yaml")
276-
if err := os.WriteFile(specPath, []byte("openapi: 3.0.0\n"), 0o644); err != nil {
277-
t.Fatalf("write spec: %v", err)
278-
}
279-
280-
rejects := []string{
281-
"foo --extra=evil", // space + flag injection
282-
"foo;rm -rf /", // command separator
283-
"foo bar", // bare space
284-
"--shell-injection", // leading dash
285-
"foo$(whoami)", // command substitution
286-
}
287-
for _, name := range rejects {
288-
t.Run(name, func(t *testing.T) {
289-
gen := &api.SDKGenerator{
290-
SpecPath: specPath,
291-
OutputDir: tmp,
292-
PackageName: name,
293-
}
294-
err := gen.Generate(context.Background(), "go")
295-
if err == nil {
296-
t.Errorf("expected rejection for PackageName=%q, got nil error", name)
297-
return
298-
}
299-
if !strings.Contains(err.Error(), "package name") {
300-
t.Errorf("expected rejection error containing 'package name', got %q", err.Error())
301-
}
302-
})
303-
}
304-
}
305-
306-
// TestSDKGenerator_Generate_PackageNameAccepted_Good verifies legitimate names
307-
// pass the regex; any subsequent error must NOT be the regex-rejection.
308-
func TestSDKGenerator_Generate_PackageNameAccepted_Good(t *testing.T) {
309-
accepts := []string{
310-
"foo",
311-
"FooBar",
312-
"foo_bar",
313-
"foo-bar",
314-
"Foo123",
315-
"a",
316-
}
317-
tmp := t.TempDir()
318-
specPath := filepath.Join(tmp, "spec.yaml")
319-
if err := os.WriteFile(specPath, []byte("openapi: 3.0.0\n"), 0o644); err != nil {
320-
t.Fatalf("write spec: %v", err)
321-
}
322-
for _, name := range accepts {
323-
t.Run(name, func(t *testing.T) {
324-
gen := &api.SDKGenerator{
325-
SpecPath: specPath,
326-
OutputDir: tmp,
327-
PackageName: name,
328-
}
329-
err := gen.Generate(context.Background(), "go")
330-
// Likely fails because openapi-generator-cli isn't installed in
331-
// CI; the error MUST NOT be the regex-rejection ("package name
332-
// X rejected").
333-
if err != nil && strings.Contains(err.Error(), "package name") &&
334-
strings.Contains(err.Error(), "rejected") {
335-
t.Errorf("name %q was unexpectedly rejected by regex: %v", name, err)
336-
}
337-
})
338-
}
339-
}

go/no_route_example_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// SPDX-License-Identifier: EUPL-1.2
2+
3+
package api_test
4+
5+
import (
6+
"fmt"
7+
"net/http"
8+
"net/http/httptest"
9+
10+
api "dappco.re/go/api"
11+
"github.com/gin-gonic/gin"
12+
)
13+
14+
// ExampleWithNoRoute shows the canonical SPA-host pattern: every
15+
// request whose path doesn't match a registered route is served the
16+
// SPA's index.html, letting the client-side router take over.
17+
func ExampleWithNoRoute() {
18+
gin.SetMode(gin.ReleaseMode)
19+
e, _ := api.New(api.WithNoRoute(func(c *gin.Context) {
20+
// Real SPA host calls c.File("dist/index.html"); the example
21+
// writes a string so the test output stays deterministic.
22+
c.String(http.StatusOK, "<!doctype html><html>SPA shell</html>")
23+
}))
24+
25+
w := httptest.NewRecorder()
26+
e.Handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/anywhere/the/client/router/cares-about", nil))
27+
fmt.Println(w.Code, w.Body.String())
28+
// Output: 200 <!doctype html><html>SPA shell</html>
29+
}

go/no_route_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// SPDX-License-Identifier: EUPL-1.2
2+
3+
package api
4+
5+
import (
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
"github.com/gin-gonic/gin"
11+
)
12+
13+
// TestWithNoRoute_Good_FiresOnUnknownPath verifies the canonical SPA-host
14+
// path: a handler set via WithNoRoute catches a request whose path doesn't
15+
// match any registered route, and the response body is whatever the
16+
// handler writes (not gin's default 404 body).
17+
func TestWithNoRoute_Good_FiresOnUnknownPath(t *testing.T) {
18+
const payload = "SPA-INDEX"
19+
20+
e, err := New(WithNoRoute(func(c *gin.Context) {
21+
c.String(http.StatusOK, payload)
22+
}))
23+
if err != nil {
24+
t.Fatalf("api.New: %v", err)
25+
}
26+
27+
w := httptest.NewRecorder()
28+
req := httptest.NewRequest(http.MethodGet, "/nonexistent/spa/route", nil)
29+
e.Handler().ServeHTTP(w, req)
30+
31+
if w.Code != http.StatusOK {
32+
t.Fatalf("status = %d, want 200", w.Code)
33+
}
34+
if got := w.Body.String(); got != payload {
35+
t.Fatalf("body = %q, want %q", got, payload)
36+
}
37+
}
38+
39+
// TestWithNoRoute_Bad_DoesNotShadowExplicitRoutes verifies the registration
40+
// order — an explicit route handler must win over the NoRoute fallback even
41+
// when both could plausibly serve the request.
42+
func TestWithNoRoute_Bad_DoesNotShadowExplicitRoutes(t *testing.T) {
43+
e, err := New(WithNoRoute(func(c *gin.Context) {
44+
c.String(http.StatusOK, "fallback")
45+
}))
46+
if err != nil {
47+
t.Fatalf("api.New: %v", err)
48+
}
49+
50+
// /health is always registered by build().
51+
w := httptest.NewRecorder()
52+
req := httptest.NewRequest(http.MethodGet, "/health", nil)
53+
e.Handler().ServeHTTP(w, req)
54+
55+
if w.Code != http.StatusOK {
56+
t.Fatalf("status = %d, want 200", w.Code)
57+
}
58+
if got := w.Body.String(); got == "fallback" {
59+
t.Fatalf("NoRoute shadowed /health — body = %q", got)
60+
}
61+
}
62+
63+
// TestWithNoRoute_Ugly_NilStaysAsDefault404 verifies the degenerate path —
64+
// no NoRoute set means gin's default 404 surfaces unchanged. This protects
65+
// the contract that WithNoRoute is opt-in and unset Engines stay
66+
// compatible with the pre-WithNoRoute API.
67+
func TestWithNoRoute_Ugly_NilStaysAsDefault404(t *testing.T) {
68+
e, err := New() // no WithNoRoute
69+
if err != nil {
70+
t.Fatalf("api.New: %v", err)
71+
}
72+
73+
w := httptest.NewRecorder()
74+
req := httptest.NewRequest(http.MethodGet, "/nope", nil)
75+
e.Handler().ServeHTTP(w, req)
76+
77+
if w.Code != http.StatusNotFound {
78+
t.Fatalf("status = %d, want 404", w.Code)
79+
}
80+
}

go/options.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,26 @@ func WithHTTP3(addr string) Option {
6262
}
6363
}
6464

65+
// WithNoRoute sets a fallback handler invoked when no registered
66+
// route matches the incoming request. The typical use is an SPA host
67+
// rewriting unknown GETs to index.html, but any gin.HandlerFunc is
68+
// valid — pass nil to clear and let Gin return its default 404.
69+
//
70+
// Registration order: NoRoute is mounted after every explicit route +
71+
// group + middleware, so it never shadows real handlers. Method
72+
// mismatches still surface as 405 when HandleMethodNotAllowed is on.
73+
//
74+
// Example:
75+
//
76+
// api.New(api.WithNoRoute(func(c *gin.Context) {
77+
// c.File("dist/index.html")
78+
// }))
79+
func WithNoRoute(h gin.HandlerFunc) Option {
80+
return func(e *Engine) {
81+
e.noRouteHandler = h
82+
}
83+
}
84+
6585
// WithBearerAuth adds bearer token authentication middleware.
6686
// Requests to /health and the Swagger UI path are exempt.
6787
//

0 commit comments

Comments
 (0)