Compare commits

..

1 Commits

Author SHA1 Message Date
unkinben 3a3b7fe7b7 feat: redirect / to the web UI (#101)
ci/woodpecker/tag/docker Pipeline was successful
## Why

The web UI ships as a separate image served under \`/ui\` (built with \`BASE_PATH=/ui\`). Hitting the bare domain (e.g. \`https://artifactapi.k8s.syd1.au.unkin.net/\`) returned the API's JSON identity blob instead of the app, so browsers never landed on the UI.

## Changes

- Redirect \`GET /\` to \`/ui/\` (302 Found).
- Preserve the former root JSON (\`{"name","version"}\`) at \`/version\`, so health/monitoring can still read the running version.
- Update the server integration test to assert the redirect and the \`/version\` payload.

Reviewed-on: #101
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-03 15:00:19 +10:00
2 changed files with 28 additions and 2 deletions
+7
View File
@@ -95,6 +95,7 @@ func (s *Server) routes() chi.Router {
r.Get("/health", s.handleHealth) r.Get("/health", s.handleHealth)
r.Get("/", s.handleRoot) r.Get("/", s.handleRoot)
r.Get("/version", s.handleVersion)
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler) proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler)
r.Mount("/api/v1", proxyHandler.Routes()) r.Mount("/api/v1", proxyHandler.Routes())
@@ -143,7 +144,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"status":"ok"}`) fmt.Fprint(w, `{"status":"ok"}`)
} }
// handleRoot sends browsers landing on the bare domain to the web UI, which is
// served under /ui. The service identity that used to live here is at /version.
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/", http.StatusFound)
}
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"name":"artifactapi","version":"%s"}`, s.version) fmt.Fprintf(w, `{"name":"artifactapi","version":"%s"}`, s.version)
+21 -2
View File
@@ -129,13 +129,32 @@ func req(t *testing.T, method, path string, body string) (*http.Response, []byte
return resp, b return resp, b
} }
// reqNoRedirect issues a request without following redirects so the response's
// status and Location header can be asserted directly.
func reqNoRedirect(t *testing.T, method, path string) *http.Response {
t.Helper()
rq, _ := http.NewRequest(method, testTS.URL+path, nil)
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := client.Do(rq)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
resp.Body.Close()
return resp
}
func TestServerHealthAndRoot(t *testing.T) { func TestServerHealthAndRoot(t *testing.T) {
requireStack(t) requireStack(t)
if resp, _ := req(t, "GET", "/health", ""); resp.StatusCode != 200 { if resp, _ := req(t, "GET", "/health", ""); resp.StatusCode != 200 {
t.Errorf("health: %d", resp.StatusCode) t.Errorf("health: %d", resp.StatusCode)
} }
if resp, b := req(t, "GET", "/", ""); resp.StatusCode != 200 || !strings.Contains(string(b), "test-version") { if resp := reqNoRedirect(t, "GET", "/"); resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != "/ui/" {
t.Errorf("root: %d %s", resp.StatusCode, b) t.Errorf("root redirect: %d %q", resp.StatusCode, resp.Header.Get("Location"))
}
if resp, b := req(t, "GET", "/version", ""); resp.StatusCode != 200 || !strings.Contains(string(b), "test-version") {
t.Errorf("version: %d %s", resp.StatusCode, b)
} }
if resp, _ := req(t, "GET", "/api/v2/health", ""); resp.StatusCode != 200 { if resp, _ := req(t, "GET", "/api/v2/health", ""); resp.StatusCode != 200 {
t.Errorf("health v2: %d", resp.StatusCode) t.Errorf("health v2: %d", resp.StatusCode)