From d9b56fd14f05e937b31dd79afc43ef4d0c6ddb1b Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 21 May 2026 16:59:27 -0300 Subject: [PATCH 1/4] fix: accept original domain in DELETE path, return 404 for not found Co-Authored-By: Claude Sonnet 4.6 --- internal/api/handlers.go | 6 +++++- internal/api/server_test.go | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 30f7dd4..13514d8 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -80,7 +80,11 @@ func (h *Handlers) delete(w http.ResponseWriter, r *http.Request) { ObjectMeta: metav1.ObjectMeta{Name: domain, Namespace: ns}, } if err := h.client.Delete(r.Context(), rd); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + status := http.StatusInternalServerError + if apierrors.IsNotFound(err) { + status = http.StatusNotFound + } + http.Error(w, err.Error(), status) return } w.WriteHeader(http.StatusNoContent) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 08b2100..a459b5a 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -69,7 +69,7 @@ func TestDelete_HappyPath(t *testing.T) { h := api.NewHandlers(fc, "deco-redirect-system") srv := api.NewServer(":0", "user", "pass", h) - req := httptest.NewRequest(http.MethodDelete, "/redirects/example-com", nil) + req := httptest.NewRequest(http.MethodDelete, "/redirects/example.com", nil) req.SetBasicAuth("user", "pass") rec := httptest.NewRecorder() srv.ServeHTTP(rec, req) From fb4fbdb18f6c460500775761d50e7519eea83ff1 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 21 May 2026 17:04:10 -0300 Subject: [PATCH 2/4] feat: add GET /redirects/{domain} endpoint Co-Authored-By: Claude Sonnet 4.6 --- internal/api/handlers.go | 17 +++++++++++++++ internal/api/server.go | 1 + internal/api/server_test.go | 42 +++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 13514d8..9132976 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -72,6 +72,23 @@ func (h *Handlers) create(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) } +func (h *Handlers) get(w http.ResponseWriter, r *http.Request) { + domain := domainToName(strings.ToLower(strings.TrimSpace(r.PathValue("domain")))) + ns := h.nsOrDefault(r.URL.Query().Get("namespace")) + + rd := &decositesv1alpha1.RedirectDomain{} + if err := h.client.Get(r.Context(), client.ObjectKey{Name: domain, Namespace: ns}, rd); err != nil { + status := http.StatusInternalServerError + if apierrors.IsNotFound(err) { + status = http.StatusNotFound + } + http.Error(w, err.Error(), status) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(rd) +} + func (h *Handlers) delete(w http.ResponseWriter, r *http.Request) { domain := domainToName(strings.ToLower(strings.TrimSpace(r.PathValue("domain")))) ns := h.nsOrDefault(r.URL.Query().Get("namespace")) diff --git a/internal/api/server.go b/internal/api/server.go index 93e4a9e..5ef5cdf 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -19,6 +19,7 @@ func NewServer(addr, user, pass string, h *Handlers) *Server { mux := http.NewServeMux() mux.HandleFunc("GET /redirects", h.list) mux.HandleFunc("POST /redirects", h.create) + mux.HandleFunc("GET /redirects/{domain}", h.get) mux.HandleFunc("DELETE /redirects/{domain}", h.delete) return &Server{ addr: addr, diff --git a/internal/api/server_test.go b/internal/api/server_test.go index a459b5a..1dfdd29 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -78,6 +78,48 @@ func TestDelete_HappyPath(t *testing.T) { } } +func TestGet_HappyPath(t *testing.T) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = decositesv1alpha1.AddToScheme(scheme) + fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&decositesv1alpha1.RedirectDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "example-com", Namespace: "deco-redirect-system"}, + Spec: decositesv1alpha1.RedirectDomainSpec{From: "example.com", To: "https://www.example.com"}, + }).Build() + h := api.NewHandlers(fc, "deco-redirect-system") + srv := api.NewServer(":0", "user", "pass", h) + + req := httptest.NewRequest(http.MethodGet, "/redirects/example.com", nil) + req.SetBasicAuth("user", "pass") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + var item decositesv1alpha1.RedirectDomain + _ = json.NewDecoder(rec.Body).Decode(&item) + if item.Spec.From != "example.com" { + t.Fatalf("expected from=example.com, got %s", item.Spec.From) + } +} + +func TestGet_NotFound(t *testing.T) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = decositesv1alpha1.AddToScheme(scheme) + fc := fake.NewClientBuilder().WithScheme(scheme).Build() + h := api.NewHandlers(fc, "deco-redirect-system") + srv := api.NewServer(":0", "user", "pass", h) + + req := httptest.NewRequest(http.MethodGet, "/redirects/notfound.com", nil) + req.SetBasicAuth("user", "pass") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } +} + func TestList_HappyPath(t *testing.T) { scheme := runtime.NewScheme() _ = clientgoscheme.AddToScheme(scheme) From 1896246377e0fbd255879774c055e692e2d38698 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 21 May 2026 17:08:34 -0300 Subject: [PATCH 3/4] fix: validate domain path param in GET and DELETE, return 400 for invalid Co-Authored-By: Claude Sonnet 4.6 --- internal/api/handlers.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 9132976..dcb8763 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -73,7 +73,12 @@ func (h *Handlers) create(w http.ResponseWriter, r *http.Request) { } func (h *Handlers) get(w http.ResponseWriter, r *http.Request) { - domain := domainToName(strings.ToLower(strings.TrimSpace(r.PathValue("domain")))) + rawDomain := strings.ToLower(strings.TrimSpace(r.PathValue("domain"))) + if !domainRe.MatchString(rawDomain) { + http.Error(w, "invalid domain", http.StatusBadRequest) + return + } + domain := domainToName(rawDomain) ns := h.nsOrDefault(r.URL.Query().Get("namespace")) rd := &decositesv1alpha1.RedirectDomain{} @@ -90,7 +95,12 @@ func (h *Handlers) get(w http.ResponseWriter, r *http.Request) { } func (h *Handlers) delete(w http.ResponseWriter, r *http.Request) { - domain := domainToName(strings.ToLower(strings.TrimSpace(r.PathValue("domain")))) + rawDomain := strings.ToLower(strings.TrimSpace(r.PathValue("domain"))) + if !domainRe.MatchString(rawDomain) { + http.Error(w, "invalid domain", http.StatusBadRequest) + return + } + domain := domainToName(rawDomain) ns := h.nsOrDefault(r.URL.Query().Get("namespace")) rd := &decositesv1alpha1.RedirectDomain{ From 362b85f8635579d5cc8fd87831899fb4ddbcdc44 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 21 May 2026 17:13:51 -0300 Subject: [PATCH 4/4] feat: return simplified response with certificateReady status from GET and LIST Co-Authored-By: Claude Sonnet 4.6 --- internal/api/handlers.go | 34 ++++++++++++++++++++++++++++++++-- internal/api/server_test.go | 13 +++++++++---- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index dcb8763..7e96cfe 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -32,6 +32,32 @@ type redirectRequest struct { Namespace string `json:"namespace,omitempty"` } +type redirectResponse struct { + From string `json:"from"` + To string `json:"to"` + CertificateReady bool `json:"certificateReady"` + Message string `json:"message,omitempty"` + CreatedAt string `json:"createdAt"` +} + +func toResponse(rd *decositesv1alpha1.RedirectDomain) redirectResponse { + resp := redirectResponse{ + From: rd.Spec.From, + To: rd.Spec.To, + CreatedAt: rd.CreationTimestamp.UTC().Format("2006-01-02T15:04:05Z"), + } + for _, c := range rd.Status.Conditions { + if c.Type == "CertificateReady" { + resp.CertificateReady = c.Status == "True" + if c.Status != "True" { + resp.Message = c.Message + } + break + } + } + return resp +} + func (h *Handlers) create(w http.ResponseWriter, r *http.Request) { var req redirectRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -91,7 +117,7 @@ func (h *Handlers) get(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(rd) + _ = json.NewEncoder(w).Encode(toResponse(rd)) } func (h *Handlers) delete(w http.ResponseWriter, r *http.Request) { @@ -125,8 +151,12 @@ func (h *Handlers) list(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } + items := make([]redirectResponse, len(list.Items)) + for i := range list.Items { + items[i] = toResponse(&list.Items[i]) + } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(list.Items) + _ = json.NewEncoder(w).Encode(items) } // domainToName converts a domain to a valid k8s resource name (dots → dashes). diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 1dfdd29..12fe781 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -96,10 +96,13 @@ func TestGet_HappyPath(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } - var item decositesv1alpha1.RedirectDomain + var item struct { + From string `json:"from"` + To string `json:"to"` + } _ = json.NewDecoder(rec.Body).Decode(&item) - if item.Spec.From != "example.com" { - t.Fatalf("expected from=example.com, got %s", item.Spec.From) + if item.From != "example.com" { + t.Fatalf("expected from=example.com, got %s", item.From) } } @@ -138,7 +141,9 @@ func TestList_HappyPath(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } - var items []decositesv1alpha1.RedirectDomain + var items []struct { + From string `json:"from"` + } _ = json.NewDecoder(rec.Body).Decode(&items) if len(items) != 1 { t.Fatalf("expected 1 item, got %d", len(items))