this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(api): add Cache API for clearing preview cache

Add DELETE /api/v1/cache endpoint with two modes:
- Clear specific URL: DELETE /api/v1/cache?url=...
- Clear all cache: DELETE /api/v1/cache

Response format follows the design doc:
- Specific URL: {"cleared": "<url>"}
- All cache: {"cleared": "all", "count": N}

Requires X-API-Key header for authorization (localhost always allowed).
Also adds DeleteAllLinkPreviews method to the Store interface.

+426 -8
+8
internal/data/gorm_store.go
··· 408 408 return s.db.WithContext(ctx).Delete(&LinkPreview{}, "url = ?", url).Error 409 409 } 410 410 411 + func (s *GormStore) DeleteAllLinkPreviews(ctx context.Context) (int, error) { 412 + result := s.db.WithContext(ctx).Where("1 = 1").Delete(&LinkPreview{}) 413 + if result.Error != nil { 414 + return 0, result.Error 415 + } 416 + return int(result.RowsAffected), nil 417 + } 418 + 411 419 func (s *GormStore) GetLinksByPopularity(ctx context.Context, limit int, offset int) ([]IRCLink, error) { 412 420 var links []IRCLink 413 421 err := s.db.WithContext(ctx).
+1
internal/data/store.go
··· 107 107 GetLinkPreview(ctx context.Context, url string) (*LinkPreview, error) 108 108 InsertLinkPreview(ctx context.Context, url string, data []byte) error 109 109 DeleteLinkPreview(ctx context.Context, url string) error 110 + DeleteAllLinkPreviews(ctx context.Context) (int, error) 110 111 111 112 // Image operations 112 113 InsertImage(ctx context.Context, title, link, url string) (int, error)
+53
internal/handler/api_v1_cache.go
··· 1 + package handler 2 + 3 + import ( 4 + "net/http" 5 + ) 6 + 7 + // APIv1CacheHandler handles requests to /api/v1/cache endpoints. 8 + // It handles: 9 + // - DELETE /api/v1/cache?url=... - Clear specific URL from cache 10 + // - DELETE /api/v1/cache - Clear all cache 11 + func (h *Handler) APIv1CacheHandler(w http.ResponseWriter, r *http.Request) { 12 + // Only DELETE method allowed 13 + if r.Method != http.MethodDelete { 14 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") 15 + return 16 + } 17 + 18 + // Check authorization - requires X-API-Key header 19 + if !isAuthorizedAPIKey(r, h.Config.AdminSecret) { 20 + writeAPIError(w, http.StatusForbidden, "forbidden", "Invalid or missing API key") 21 + return 22 + } 23 + 24 + ctx := r.Context() 25 + 26 + // Check if specific URL is provided 27 + urlParam := r.URL.Query().Get("url") 28 + 29 + if urlParam != "" { 30 + // Clear specific URL from cache 31 + if err := h.Store.DeleteLinkPreview(ctx, urlParam); err != nil { 32 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to clear cache") 33 + return 34 + } 35 + 36 + writeJSON(w, http.StatusOK, APICacheClearResponse{ 37 + Cleared: urlParam, 38 + }) 39 + return 40 + } 41 + 42 + // Clear all cache 43 + count, err := h.Store.DeleteAllLinkPreviews(ctx) 44 + if err != nil { 45 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to clear cache") 46 + return 47 + } 48 + 49 + writeJSON(w, http.StatusOK, APICacheClearResponse{ 50 + Cleared: "all", 51 + Count: count, 52 + }) 53 + }
+335
internal/handler/api_v1_cache_test.go
··· 1 + package handler 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "testing" 9 + 10 + "tumble/internal/config" 11 + "tumble/internal/data" 12 + ) 13 + 14 + // mockCacheStore is a mock implementation of data.Store for testing cache handlers. 15 + type mockCacheStore struct { 16 + data.Store 17 + deleteLinkPreviewFn func(url string) error 18 + deleteAllLinkPreviewFn func() (int, error) 19 + err error 20 + } 21 + 22 + func (m *mockCacheStore) DeleteLinkPreview(ctx context.Context, url string) error { 23 + if m.deleteLinkPreviewFn != nil { 24 + return m.deleteLinkPreviewFn(url) 25 + } 26 + if m.err != nil { 27 + return m.err 28 + } 29 + return nil 30 + } 31 + 32 + func (m *mockCacheStore) DeleteAllLinkPreviews(ctx context.Context) (int, error) { 33 + if m.deleteAllLinkPreviewFn != nil { 34 + return m.deleteAllLinkPreviewFn() 35 + } 36 + if m.err != nil { 37 + return 0, m.err 38 + } 39 + return 0, nil 40 + } 41 + 42 + func TestAPIv1_ClearCacheSpecificURL(t *testing.T) { 43 + tests := []struct { 44 + name string 45 + method string 46 + path string 47 + remoteAddr string 48 + apiKey string 49 + adminSecret string 50 + deleteFn func(url string) error 51 + expectedStatus int 52 + checkBody func(t *testing.T, body []byte) 53 + }{ 54 + { 55 + name: "clears specific URL returns cleared URL", 56 + method: http.MethodDelete, 57 + path: "/api/v1/cache?url=https://example.com/article", 58 + remoteAddr: "127.0.0.1:12345", 59 + expectedStatus: http.StatusOK, 60 + checkBody: func(t *testing.T, body []byte) { 61 + var resp APICacheClearResponse 62 + if err := json.Unmarshal(body, &resp); err != nil { 63 + t.Fatalf("failed to unmarshal response: %v", err) 64 + } 65 + if resp.Cleared != "https://example.com/article" { 66 + t.Errorf("expected cleared 'https://example.com/article', got %s", resp.Cleared) 67 + } 68 + }, 69 + }, 70 + { 71 + name: "localhost is authorized without key", 72 + method: http.MethodDelete, 73 + path: "/api/v1/cache?url=https://example.com/test", 74 + remoteAddr: "127.0.0.1:12345", 75 + adminSecret: "secret123", 76 + expectedStatus: http.StatusOK, 77 + checkBody: func(t *testing.T, body []byte) { 78 + var resp APICacheClearResponse 79 + if err := json.Unmarshal(body, &resp); err != nil { 80 + t.Fatalf("failed to unmarshal response: %v", err) 81 + } 82 + if resp.Cleared != "https://example.com/test" { 83 + t.Errorf("expected cleared URL, got %s", resp.Cleared) 84 + } 85 + }, 86 + }, 87 + { 88 + name: "valid API key authorizes request", 89 + method: http.MethodDelete, 90 + path: "/api/v1/cache?url=https://example.com/test", 91 + remoteAddr: "192.168.1.100:12345", 92 + apiKey: "secret123", 93 + adminSecret: "secret123", 94 + expectedStatus: http.StatusOK, 95 + checkBody: func(t *testing.T, body []byte) { 96 + var resp APICacheClearResponse 97 + if err := json.Unmarshal(body, &resp); err != nil { 98 + t.Fatalf("failed to unmarshal response: %v", err) 99 + } 100 + if resp.Cleared != "https://example.com/test" { 101 + t.Errorf("expected cleared URL, got %s", resp.Cleared) 102 + } 103 + }, 104 + }, 105 + { 106 + name: "unauthorized without key returns 403", 107 + method: http.MethodDelete, 108 + path: "/api/v1/cache?url=https://example.com/test", 109 + remoteAddr: "192.168.1.100:12345", 110 + adminSecret: "secret123", 111 + expectedStatus: http.StatusForbidden, 112 + checkBody: func(t *testing.T, body []byte) { 113 + var resp APIErrorResponse 114 + if err := json.Unmarshal(body, &resp); err != nil { 115 + t.Fatalf("failed to unmarshal response: %v", err) 116 + } 117 + if resp.Error.Code != "forbidden" { 118 + t.Errorf("expected code 'forbidden', got %s", resp.Error.Code) 119 + } 120 + }, 121 + }, 122 + { 123 + name: "wrong API key returns 403", 124 + method: http.MethodDelete, 125 + path: "/api/v1/cache?url=https://example.com/test", 126 + remoteAddr: "192.168.1.100:12345", 127 + apiKey: "wrongkey", 128 + adminSecret: "secret123", 129 + expectedStatus: http.StatusForbidden, 130 + checkBody: func(t *testing.T, body []byte) { 131 + var resp APIErrorResponse 132 + if err := json.Unmarshal(body, &resp); err != nil { 133 + t.Fatalf("failed to unmarshal response: %v", err) 134 + } 135 + if resp.Error.Code != "forbidden" { 136 + t.Errorf("expected code 'forbidden', got %s", resp.Error.Code) 137 + } 138 + }, 139 + }, 140 + { 141 + name: "only DELETE method allowed", 142 + method: http.MethodGet, 143 + path: "/api/v1/cache?url=https://example.com/test", 144 + remoteAddr: "127.0.0.1:12345", 145 + expectedStatus: http.StatusMethodNotAllowed, 146 + checkBody: func(t *testing.T, body []byte) { 147 + var resp APIErrorResponse 148 + if err := json.Unmarshal(body, &resp); err != nil { 149 + t.Fatalf("failed to unmarshal response: %v", err) 150 + } 151 + if resp.Error.Code != "method_not_allowed" { 152 + t.Errorf("expected code 'method_not_allowed', got %s", resp.Error.Code) 153 + } 154 + }, 155 + }, 156 + { 157 + name: "POST method not allowed", 158 + method: http.MethodPost, 159 + path: "/api/v1/cache?url=https://example.com/test", 160 + remoteAddr: "127.0.0.1:12345", 161 + expectedStatus: http.StatusMethodNotAllowed, 162 + checkBody: func(t *testing.T, body []byte) { 163 + var resp APIErrorResponse 164 + if err := json.Unmarshal(body, &resp); err != nil { 165 + t.Fatalf("failed to unmarshal response: %v", err) 166 + } 167 + if resp.Error.Code != "method_not_allowed" { 168 + t.Errorf("expected code 'method_not_allowed', got %s", resp.Error.Code) 169 + } 170 + }, 171 + }, 172 + { 173 + name: "store error returns 500", 174 + method: http.MethodDelete, 175 + path: "/api/v1/cache?url=https://example.com/test", 176 + remoteAddr: "127.0.0.1:12345", 177 + deleteFn: func(url string) error { 178 + return context.DeadlineExceeded 179 + }, 180 + expectedStatus: http.StatusInternalServerError, 181 + checkBody: func(t *testing.T, body []byte) { 182 + var resp APIErrorResponse 183 + if err := json.Unmarshal(body, &resp); err != nil { 184 + t.Fatalf("failed to unmarshal response: %v", err) 185 + } 186 + if resp.Error.Code != "internal_error" { 187 + t.Errorf("expected code 'internal_error', got %s", resp.Error.Code) 188 + } 189 + }, 190 + }, 191 + } 192 + 193 + for _, tt := range tests { 194 + t.Run(tt.name, func(t *testing.T) { 195 + store := &mockCacheStore{ 196 + deleteLinkPreviewFn: tt.deleteFn, 197 + } 198 + handler := &Handler{ 199 + Store: store, 200 + Config: &config.Config{ 201 + AdminSecret: tt.adminSecret, 202 + }, 203 + } 204 + 205 + req := httptest.NewRequest(tt.method, tt.path, nil) 206 + req.RemoteAddr = tt.remoteAddr 207 + if tt.apiKey != "" { 208 + req.Header.Set("X-API-Key", tt.apiKey) 209 + } 210 + w := httptest.NewRecorder() 211 + 212 + handler.APIv1CacheHandler(w, req) 213 + 214 + if w.Code != tt.expectedStatus { 215 + t.Errorf("expected status %d, got %d. Body: %s", tt.expectedStatus, w.Code, w.Body.String()) 216 + } 217 + 218 + contentType := w.Header().Get("Content-Type") 219 + if contentType != "application/json" { 220 + t.Errorf("expected Content-Type application/json, got %s", contentType) 221 + } 222 + 223 + if tt.checkBody != nil { 224 + tt.checkBody(t, w.Body.Bytes()) 225 + } 226 + }) 227 + } 228 + } 229 + 230 + func TestAPIv1_ClearAllCache(t *testing.T) { 231 + tests := []struct { 232 + name string 233 + method string 234 + path string 235 + remoteAddr string 236 + deleteAllFn func() (int, error) 237 + expectedStatus int 238 + checkBody func(t *testing.T, body []byte) 239 + }{ 240 + { 241 + name: "clear all cache returns 'all' with count", 242 + method: http.MethodDelete, 243 + path: "/api/v1/cache", 244 + remoteAddr: "127.0.0.1:12345", 245 + deleteAllFn: func() (int, error) { 246 + return 47, nil 247 + }, 248 + expectedStatus: http.StatusOK, 249 + checkBody: func(t *testing.T, body []byte) { 250 + var resp APICacheClearResponse 251 + if err := json.Unmarshal(body, &resp); err != nil { 252 + t.Fatalf("failed to unmarshal response: %v", err) 253 + } 254 + if resp.Cleared != "all" { 255 + t.Errorf("expected cleared 'all', got %s", resp.Cleared) 256 + } 257 + if resp.Count != 47 { 258 + t.Errorf("expected count 47, got %d", resp.Count) 259 + } 260 + }, 261 + }, 262 + { 263 + name: "clear all cache with zero entries", 264 + method: http.MethodDelete, 265 + path: "/api/v1/cache", 266 + remoteAddr: "127.0.0.1:12345", 267 + deleteAllFn: func() (int, error) { 268 + return 0, nil 269 + }, 270 + expectedStatus: http.StatusOK, 271 + checkBody: func(t *testing.T, body []byte) { 272 + var resp APICacheClearResponse 273 + if err := json.Unmarshal(body, &resp); err != nil { 274 + t.Fatalf("failed to unmarshal response: %v", err) 275 + } 276 + if resp.Cleared != "all" { 277 + t.Errorf("expected cleared 'all', got %s", resp.Cleared) 278 + } 279 + if resp.Count != 0 { 280 + t.Errorf("expected count 0, got %d", resp.Count) 281 + } 282 + }, 283 + }, 284 + { 285 + name: "store error returns 500", 286 + method: http.MethodDelete, 287 + path: "/api/v1/cache", 288 + remoteAddr: "127.0.0.1:12345", 289 + deleteAllFn: func() (int, error) { 290 + return 0, context.DeadlineExceeded 291 + }, 292 + expectedStatus: http.StatusInternalServerError, 293 + checkBody: func(t *testing.T, body []byte) { 294 + var resp APIErrorResponse 295 + if err := json.Unmarshal(body, &resp); err != nil { 296 + t.Fatalf("failed to unmarshal response: %v", err) 297 + } 298 + if resp.Error.Code != "internal_error" { 299 + t.Errorf("expected code 'internal_error', got %s", resp.Error.Code) 300 + } 301 + }, 302 + }, 303 + } 304 + 305 + for _, tt := range tests { 306 + t.Run(tt.name, func(t *testing.T) { 307 + store := &mockCacheStore{ 308 + deleteAllLinkPreviewFn: tt.deleteAllFn, 309 + } 310 + handler := &Handler{ 311 + Store: store, 312 + Config: &config.Config{}, 313 + } 314 + 315 + req := httptest.NewRequest(tt.method, tt.path, nil) 316 + req.RemoteAddr = tt.remoteAddr 317 + w := httptest.NewRecorder() 318 + 319 + handler.APIv1CacheHandler(w, req) 320 + 321 + if w.Code != tt.expectedStatus { 322 + t.Errorf("expected status %d, got %d. Body: %s", tt.expectedStatus, w.Code, w.Body.String()) 323 + } 324 + 325 + contentType := w.Header().Get("Content-Type") 326 + if contentType != "application/json" { 327 + t.Errorf("expected Content-Type application/json, got %s", contentType) 328 + } 329 + 330 + if tt.checkBody != nil { 331 + tt.checkBody(t, w.Body.Bytes()) 332 + } 333 + }) 334 + } 335 + }
+6 -4
internal/handler/api_v1_types.go
··· 105 105 Meta APISearchMeta `json:"meta"` 106 106 } 107 107 108 - // APICacheResponse represents the response from cache operations. 109 - type APICacheResponse struct { 110 - Cleared bool `json:"cleared"` 111 - Count int `json:"count"` 108 + // APICacheClearResponse represents the response from cache clear operations. 109 + // Cleared is either the specific URL that was cleared, or "all" if all cache was cleared. 110 + // Count is only present when clearing all cache. 111 + type APICacheClearResponse struct { 112 + Cleared string `json:"cleared"` 113 + Count int `json:"count,omitempty"` 112 114 } 113 115 114 116 // APIKittenResponse represents the response from kitten operations.
+23 -4
internal/handler/api_v1_types_test.go
··· 551 551 } 552 552 } 553 553 554 - func TestAPICacheResponse_JSONMarshal(t *testing.T) { 555 - resp := APICacheResponse{ 556 - Cleared: true, 554 + func TestAPICacheClearResponse_JSONMarshal(t *testing.T) { 555 + resp := APICacheClearResponse{ 556 + Cleared: "all", 557 557 Count: 42, 558 558 } 559 559 560 560 data, err := json.Marshal(resp) 561 561 if err != nil { 562 - t.Fatalf("Failed to marshal APICacheResponse: %v", err) 562 + t.Fatalf("Failed to marshal APICacheClearResponse: %v", err) 563 563 } 564 564 565 565 jsonStr := string(data) ··· 570 570 } 571 571 if !containsJSON(jsonStr, `"count":`) { 572 572 t.Errorf("Expected 'count' field, got: %s", jsonStr) 573 + } 574 + } 575 + 576 + func TestAPICacheClearResponse_OmitEmptyCount(t *testing.T) { 577 + resp := APICacheClearResponse{ 578 + Cleared: "https://example.com/article", 579 + // Count is 0, should be omitted 580 + } 581 + 582 + data, err := json.Marshal(resp) 583 + if err != nil { 584 + t.Fatalf("Failed to marshal APICacheClearResponse: %v", err) 585 + } 586 + 587 + jsonStr := string(data) 588 + 589 + // count should be omitted when zero (for specific URL clearing) 590 + if containsJSON(jsonStr, `"count":`) { 591 + t.Errorf("Expected 'count' to be omitted when zero, got: %s", jsonStr) 573 592 } 574 593 } 575 594