From bbc6ba20dfcbdb8895b62b662c62458a5dfc1794 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Fri, 17 Apr 2026 14:10:32 +0700 Subject: [PATCH] fix: return JSON 404 for unmatched API routes Unmatched API routes (e.g. GET /api/v1/nonexistent) previously returned Gin's default plain-text "404 page not found". Now they return a JSON envelope consistent with the rest of the API: {"status": 404, "message": "not found"} Both portal modes (proxy and embedded) are updated. Non-API routes continue to work as before (SPA fallback or proxy). Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/portal/embed.go | 15 +++++++-- internal/portal/embed_test.go | 62 +++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 internal/portal/embed_test.go diff --git a/internal/portal/embed.go b/internal/portal/embed.go index 848f46656..c01f4c284 100644 --- a/internal/portal/embed.go +++ b/internal/portal/embed.go @@ -47,8 +47,11 @@ func AddRoutes(router *gin.Engine, config PortalConfig) { } proxy := httputil.NewSingleHostReverseProxy(remote) router.NoRoute(func(c *gin.Context) { - if strings.HasPrefix(c.Request.URL.Path, "/api") { - c.Next() + if strings.HasPrefix(c.Request.URL.Path, "/api/") { + c.JSON(http.StatusNotFound, gin.H{ + "status": http.StatusNotFound, + "message": "not found", + }) return } if c.Request.Method != "GET" { @@ -60,6 +63,14 @@ func AddRoutes(router *gin.Engine, config PortalConfig) { } else { embeddedBuildFolder := newStaticFileSystem() fallbackFileSystem := newFallbackFileSystem(embeddedBuildFolder) + router.NoRoute(func(c *gin.Context) { + if strings.HasPrefix(c.Request.URL.Path, "/api/") { + c.JSON(http.StatusNotFound, gin.H{ + "status": http.StatusNotFound, + "message": "not found", + }) + } + }) router.Use(static.Serve("/", embeddedBuildFolder)) router.Use(static.Serve("/", fallbackFileSystem)) } diff --git a/internal/portal/embed_test.go b/internal/portal/embed_test.go new file mode 100644 index 000000000..fc4fe49e3 --- /dev/null +++ b/internal/portal/embed_test.go @@ -0,0 +1,62 @@ +package portal + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func TestAddRoutes_NoRoute_APIReturnsJSON404(t *testing.T) { + t.Parallel() + + t.Run("embedded mode returns JSON 404 for unmatched API routes", func(t *testing.T) { + t.Parallel() + + router := gin.New() + AddRoutes(router, PortalConfig{}) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/nonexistent", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, float64(http.StatusNotFound), response["status"]) + assert.Equal(t, "not found", response["message"]) + }) + + t.Run("proxy mode returns JSON 404 for unmatched API routes", func(t *testing.T) { + t.Parallel() + + router := gin.New() + AddRoutes(router, PortalConfig{ + ProxyURL: "http://localhost:19999", + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/nonexistent", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, float64(http.StatusNotFound), response["status"]) + assert.Equal(t, "not found", response["message"]) + }) +}