Add face service package for AWS Rekognition integration
This commit is contained in:
parent
4bf37faf4f
commit
6568af4dba
@ -16,6 +16,7 @@ import (
|
||||
type Handler struct {
|
||||
Repo store.Repository
|
||||
Debug bool
|
||||
FaceService *face.Service
|
||||
}
|
||||
|
||||
// New creates a new Handler with the given store.
|
||||
@ -23,6 +24,14 @@ func New(repo store.Repository) *Handler {
|
||||
return &Handler{Repo: repo}
|
||||
}
|
||||
|
||||
// NewWithFaceService creates a new Handler with face search service.
|
||||
func NewWithFaceService(repo store.Repository, faceService *face.Service) *Handler {
|
||||
return &Handler{
|
||||
Repo: repo,
|
||||
FaceService: faceService,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts all routes on the given chi router.
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Use(middleware.Logger)
|
||||
@ -46,6 +55,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
// JSON API v1 routes.
|
||||
h.RegisterV1Routes(r)
|
||||
|
||||
// Space management routes.
|
||||
h.RegisterSpaceRoutes(r)
|
||||
|
||||
// Inspect page: server-rendered request list.
|
||||
r.Get("/e/{endpointID}", HandleInspect(h.Repo))
|
||||
|
||||
|
||||
383
internal/handler/spaces.go
Normal file
383
internal/handler/spaces.go
Normal file
@ -0,0 +1,383 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/elfoundation/hatch/internal/store"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// RegisterSpaceRoutes mounts the space management routes on the given router.
|
||||
func (h *Handler) RegisterSpaceRoutes(r chi.Router) {
|
||||
r.Route("/api/spaces", func(r chi.Router) {
|
||||
// Public endpoints
|
||||
r.Post("/", h.createSpace)
|
||||
r.Get("/", h.listPublicSpaces)
|
||||
r.Get("/{slug}", h.getSpaceBySlug)
|
||||
r.Patch("/{slug}", h.updateSpace)
|
||||
r.Delete("/{slug}", h.deleteSpace)
|
||||
|
||||
// Photo management
|
||||
r.Post("/{slug}/photos", h.createPhoto)
|
||||
r.Get("/{slug}/photos", h.listPhotos)
|
||||
|
||||
// Face search endpoints
|
||||
r.Post("/{slug}/face-search/guest", h.guestFaceSearch)
|
||||
r.Post("/{slug}/face-search", h.authenticatedFaceSearch)
|
||||
})
|
||||
}
|
||||
|
||||
// createSpace handles POST /api/spaces
|
||||
func (h *Handler) createSpace(w http.ResponseWriter, r *http.Request) {
|
||||
var space store.Space
|
||||
if err := json.NewDecoder(r.Body).Decode(&space); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Generate slug if not provided
|
||||
if space.Slug == "" {
|
||||
space.Slug = generateSlug(space.Name)
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if space.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
createdSpace, err := h.Repo.CreateSpace(ctx, &space)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create space: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(createdSpace)
|
||||
}
|
||||
|
||||
// listPublicSpaces handles GET /api/spaces
|
||||
func (h *Handler) listPublicSpaces(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
spaces, err := h.Repo.ListPublicSpaces(ctx)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list spaces")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(spaces)
|
||||
}
|
||||
|
||||
// getSpaceBySlug handles GET /api/spaces/{slug}
|
||||
func (h *Handler) getSpaceBySlug(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
if slug == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
space, err := h.Repo.GetSpaceBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "space not found")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(space)
|
||||
}
|
||||
|
||||
// updateSpace handles PATCH /api/spaces/{slug}
|
||||
func (h *Handler) updateSpace(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
if slug == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
existingSpace, err := h.Repo.GetSpaceBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "space not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields from request body
|
||||
var updates store.Space
|
||||
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
if updates.Name != "" {
|
||||
existingSpace.Name = updates.Name
|
||||
}
|
||||
if updates.Description != "" {
|
||||
existingSpace.Description = updates.Description
|
||||
}
|
||||
// Note: is_public is a boolean, so we need to check if it was explicitly set
|
||||
// For simplicity, we'll always update it if provided in the request
|
||||
existingSpace.IsPublic = updates.IsPublic
|
||||
|
||||
updatedSpace, err := h.Repo.UpdateSpace(ctx, existingSpace)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update space: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(updatedSpace)
|
||||
}
|
||||
|
||||
// deleteSpace handles DELETE /api/spaces/{slug}
|
||||
func (h *Handler) deleteSpace(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
if slug == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
space, err := h.Repo.GetSpaceBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "space not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Repo.DeleteSpace(ctx, space.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete space: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// createPhoto handles POST /api/spaces/{slug}/photos
|
||||
func (h *Handler) createPhoto(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
if slug == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
space, err := h.Repo.GetSpaceBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "space not found")
|
||||
return
|
||||
}
|
||||
|
||||
var photo store.Photo
|
||||
if err := json.NewDecoder(r.Body).Decode(&photo); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if photo.URL == "" {
|
||||
writeError(w, http.StatusBadRequest, "url is required")
|
||||
return
|
||||
}
|
||||
|
||||
photo.SpaceID = space.ID
|
||||
createdPhoto, err := h.Repo.CreatePhoto(ctx, &photo)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create photo: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(createdPhoto)
|
||||
}
|
||||
|
||||
// listPhotos handles GET /api/spaces/{slug}/photos
|
||||
func (h *Handler) listPhotos(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
if slug == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
space, err := h.Repo.GetSpaceBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "space not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse limit parameter
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 100
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
photos, err := h.Repo.ListPhotosBySpace(ctx, space.ID, limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list photos")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(photos)
|
||||
}
|
||||
|
||||
// guestFaceSearch handles POST /api/spaces/{slug}/face-search/guest
|
||||
func (h *Handler) guestFaceSearch(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
if slug == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
space, err := h.Repo.GetSpaceBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "space not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check rate limit (20 requests per 15 minutes per IP)
|
||||
ipAddress := getClientIP(r)
|
||||
allowed, err := h.Repo.CheckRateLimit(ctx, ipAddress, space.ID, 20, 15)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to check rate limit")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return
|
||||
}
|
||||
|
||||
// Increment rate limit
|
||||
if err := h.Repo.IncrementRateLimit(ctx, ipAddress, space.ID, 15); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to increment rate limit")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body (should contain image data or URL)
|
||||
var request struct {
|
||||
ImageURL string `json:"imageUrl,omitempty"`
|
||||
ImageData string `json:"imageData,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Check if face service is available
|
||||
if h.FaceService == nil {
|
||||
writeError(w, http.StatusInternalServerError, "face search service not configured")
|
||||
return
|
||||
}
|
||||
|
||||
// Convert base64 image data to bytes
|
||||
var imageBytes []byte
|
||||
if request.ImageData != "" {
|
||||
// Decode base64 image data
|
||||
imageBytes, err = base64.StdEncoding.DecodeString(request.ImageData)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid image data")
|
||||
return
|
||||
}
|
||||
} else if request.ImageURL != "" {
|
||||
// Download image from URL
|
||||
resp, err := http.Get(request.ImageURL)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "failed to download image")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
imageBytes, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to read image")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
writeError(w, http.StatusBadRequest, "image data or URL required")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate image
|
||||
if err := h.FaceService.ValidateImage(imageBytes); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Search faces
|
||||
result, err := h.FaceService.SearchFaces(ctx, space.ID, imageBytes, 10)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "face search failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Format response
|
||||
response := map[string]interface{}{
|
||||
"status": "success",
|
||||
"matches": result.Matches,
|
||||
"photoCount": result.PhotoCount,
|
||||
"searchTime": result.SearchTime.Milliseconds(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// authenticatedFaceSearch handles POST /api/spaces/{slug}/face-search
|
||||
func (h *Handler) authenticatedFaceSearch(w http.ResponseWriter, r *http.Request) {
|
||||
// For now, delegate to guest face search
|
||||
h.guestFaceSearch(w, r)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func generateSlug(name string) string {
|
||||
// Convert to lowercase, replace spaces with hyphens, remove non-alphanumeric characters
|
||||
slug := strings.ToLower(name)
|
||||
slug = strings.ReplaceAll(slug, " ", "-")
|
||||
slug = strings.ReplaceAll(slug, "_", "-")
|
||||
// Remove non-alphanumeric characters except hyphens
|
||||
var result strings.Builder
|
||||
for _, c := range slug {
|
||||
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' {
|
||||
result.WriteRune(c)
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func getClientIP(r *http.Request) string {
|
||||
// Check X-Forwarded-For header first
|
||||
xForwardedFor := r.Header.Get("X-Forwarded-For")
|
||||
if xForwardedFor != "" {
|
||||
// X-Forwarded-For may contain multiple IPs, take the first one
|
||||
parts := strings.Split(xForwardedFor, ",")
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
|
||||
// Check X-Real-IP header
|
||||
xRealIP := r.Header.Get("X-Real-IP")
|
||||
if xRealIP != "" {
|
||||
return xRealIP
|
||||
}
|
||||
|
||||
// Fall back to RemoteAddr
|
||||
parts := strings.Split(r.RemoteAddr, ":")
|
||||
if len(parts) > 1 {
|
||||
return strings.Join(parts[:len(parts)-1], ":")
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
295
internal/handler/spaces_test.go
Normal file
295
internal/handler/spaces_test.go
Normal file
@ -0,0 +1,295 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/elfoundation/hatch/internal/store"
|
||||
"github.com/elfoundation/hatch/internal/testutil"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// testSpaceRouter creates a chi router with all routes registered using a fake repo.
|
||||
func testSpaceRouter(repo store.Repository) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
h := New(repo)
|
||||
h.RegisterRoutes(r)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestCreateSpace(t *testing.T) {
|
||||
repo := testutil.NewFakeRepository()
|
||||
r := testSpaceRouter(repo)
|
||||
|
||||
body := `{"name": "Test Space", "description": "A test space", "isPublic": true}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/spaces", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var space store.Space
|
||||
if err := json.NewDecoder(w.Body).Decode(&space); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if space.Name != "Test Space" {
|
||||
t.Errorf("expected name 'Test Space', got %q", space.Name)
|
||||
}
|
||||
if space.Description != "A test space" {
|
||||
t.Errorf("expected description 'A test space', got %q", space.Description)
|
||||
}
|
||||
if !space.IsPublic {
|
||||
t.Error("expected isPublic to be true")
|
||||
}
|
||||
if space.Slug == "" {
|
||||
t.Error("expected slug to be generated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSpaceMissingName(t *testing.T) {
|
||||
repo := testutil.NewFakeRepository()
|
||||
r := testSpaceRouter(repo)
|
||||
|
||||
body := `{"description": "Missing name"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/spaces", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPublicSpaces(t *testing.T) {
|
||||
repo := testutil.NewFakeRepository()
|
||||
r := testSpaceRouter(repo)
|
||||
|
||||
// Create some spaces
|
||||
space1 := &store.Space{ID: "1", Slug: "space-1", Name: "Space 1", IsPublic: true}
|
||||
space2 := &store.Space{ID: "2", Slug: "space-2", Name: "Space 2", IsPublic: false}
|
||||
space3 := &store.Space{ID: "3", Slug: "space-3", Name: "Space 3", IsPublic: true}
|
||||
|
||||
repo.CreateSpace(nil, space1)
|
||||
repo.CreateSpace(nil, space2)
|
||||
repo.CreateSpace(nil, space3)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/spaces", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var spaces []*store.Space
|
||||
if err := json.NewDecoder(w.Body).Decode(&spaces); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if len(spaces) != 2 {
|
||||
t.Fatalf("expected 2 spaces, got %d", len(spaces))
|
||||
}
|
||||
|
||||
// Check that only public spaces are returned
|
||||
for _, space := range spaces {
|
||||
if !space.IsPublic {
|
||||
t.Errorf("expected only public spaces, got %q", space.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSpaceBySlug(t *testing.T) {
|
||||
repo := testutil.NewFakeRepository()
|
||||
r := testSpaceRouter(repo)
|
||||
|
||||
space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true}
|
||||
repo.CreateSpace(nil, space)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/spaces/test-space", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var retrievedSpace store.Space
|
||||
if err := json.NewDecoder(w.Body).Decode(&retrievedSpace); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if retrievedSpace.ID != space.ID {
|
||||
t.Errorf("expected ID %q, got %q", space.ID, retrievedSpace.ID)
|
||||
}
|
||||
if retrievedSpace.Name != space.Name {
|
||||
t.Errorf("expected name %q, got %q", space.Name, retrievedSpace.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSpaceBySlugNotFound(t *testing.T) {
|
||||
repo := testutil.NewFakeRepository()
|
||||
r := testSpaceRouter(repo)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/spaces/nonexistent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSpace(t *testing.T) {
|
||||
repo := testutil.NewFakeRepository()
|
||||
r := testSpaceRouter(repo)
|
||||
|
||||
space := &store.Space{ID: "1", Slug: "test-space", Name: "Original Name", IsPublic: true}
|
||||
repo.CreateSpace(nil, space)
|
||||
|
||||
body := `{"name": "Updated Name", "isPublic": false}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/spaces/test-space", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var updatedSpace store.Space
|
||||
if err := json.NewDecoder(w.Body).Decode(&updatedSpace); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if updatedSpace.Name != "Updated Name" {
|
||||
t.Errorf("expected name 'Updated Name', got %q", updatedSpace.Name)
|
||||
}
|
||||
if updatedSpace.IsPublic != false {
|
||||
t.Error("expected isPublic to be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSpace(t *testing.T) {
|
||||
repo := testutil.NewFakeRepository()
|
||||
r := testSpaceRouter(repo)
|
||||
|
||||
space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true}
|
||||
repo.CreateSpace(nil, space)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/spaces/test-space", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify space was deleted
|
||||
_, err := repo.GetSpaceBySlug(nil, "test-space")
|
||||
if err == nil {
|
||||
t.Error("expected space to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePhoto(t *testing.T) {
|
||||
repo := testutil.NewFakeRepository()
|
||||
r := testSpaceRouter(repo)
|
||||
|
||||
space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true}
|
||||
repo.CreateSpace(nil, space)
|
||||
|
||||
body := `{"url": "https://example.com/photo.jpg", "thumbnailUrl": "https://example.com/thumb.jpg", "faceCount": 2}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/spaces/test-space/photos", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var photo store.Photo
|
||||
if err := json.NewDecoder(w.Body).Decode(&photo); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if photo.URL != "https://example.com/photo.jpg" {
|
||||
t.Errorf("expected URL 'https://example.com/photo.jpg', got %q", photo.URL)
|
||||
}
|
||||
if photo.ThumbnailURL != "https://example.com/thumb.jpg" {
|
||||
t.Errorf("expected thumbnail URL 'https://example.com/thumb.jpg', got %q", photo.ThumbnailURL)
|
||||
}
|
||||
if photo.FaceCount != 2 {
|
||||
t.Errorf("expected face count 2, got %d", photo.FaceCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPhotos(t *testing.T) {
|
||||
repo := testutil.NewFakeRepository()
|
||||
r := testSpaceRouter(repo)
|
||||
|
||||
space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true}
|
||||
repo.CreateSpace(nil, space)
|
||||
|
||||
// Create some photos
|
||||
photo1 := &store.Photo{ID: "1", SpaceID: "1", URL: "https://example.com/photo1.jpg"}
|
||||
photo2 := &store.Photo{ID: "2", SpaceID: "1", URL: "https://example.com/photo2.jpg"}
|
||||
photo3 := &store.Photo{ID: "3", SpaceID: "2", URL: "https://example.com/photo3.jpg"}
|
||||
|
||||
repo.CreatePhoto(nil, photo1)
|
||||
repo.CreatePhoto(nil, photo2)
|
||||
repo.CreatePhoto(nil, photo3)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/spaces/test-space/photos", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var photos []*store.Photo
|
||||
if err := json.NewDecoder(w.Body).Decode(&photos); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if len(photos) != 2 {
|
||||
t.Fatalf("expected 2 photos, got %d", len(photos))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestFaceSearch(t *testing.T) {
|
||||
repo := testutil.NewFakeRepository()
|
||||
r := testSpaceRouter(repo)
|
||||
|
||||
space := &store.Space{ID: "1", Slug: "test-space", Name: "Test Space", IsPublic: true}
|
||||
repo.CreateSpace(nil, space)
|
||||
|
||||
body := `{"imageUrl": "https://example.com/test.jpg"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/spaces/test-space/face-search/guest", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.RemoteAddr = "192.168.1.1:12345"
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response["status"] != "success" {
|
||||
t.Errorf("expected status 'success', got %q", response["status"])
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,9 @@ import (
|
||||
//go:embed schema.sql
|
||||
var schemaSQL string
|
||||
|
||||
//go:embed schema_spaces.sql
|
||||
var schemaSpacesSQL string
|
||||
|
||||
func Open(dbPath string) (Repository, error) {
|
||||
if dbPath == "" {
|
||||
dbPath = filepath.Join("data", "hatch.db")
|
||||
@ -39,9 +42,17 @@ func Open(dbPath string) (Repository, error) {
|
||||
}
|
||||
|
||||
func migrate(db *sql.DB) error {
|
||||
// Load main schema
|
||||
_, err := db.Exec(schemaSQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store/migrate: %w", err)
|
||||
}
|
||||
|
||||
// Load spaces schema
|
||||
_, err = db.Exec(schemaSpacesSQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store/migrate spaces: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -28,7 +28,50 @@ type MockConfig struct {
|
||||
Body []byte `json:"body"`
|
||||
}
|
||||
|
||||
// Space represents a public or private demo space
|
||||
type Space struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Photo represents a photo in a space gallery
|
||||
type Photo struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"spaceId"`
|
||||
URL string `json:"url"`
|
||||
ThumbnailURL string `json:"thumbnailUrl"`
|
||||
FaceCount int `json:"faceCount"`
|
||||
Metadata string `json:"metadata"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// FaceEmbedding stores face embeddings for search
|
||||
type FaceEmbedding struct {
|
||||
ID string `json:"id"`
|
||||
PhotoID string `json:"photoId"`
|
||||
SpaceID string `json:"spaceId"`
|
||||
Embedding []float32 `json:"embedding"`
|
||||
BoundingBox string `json:"boundingBox"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// RateLimit tracks rate limiting for guest face search
|
||||
type RateLimit struct {
|
||||
ID string `json:"id"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
SpaceID string `json:"spaceId"`
|
||||
RequestCount int `json:"requestCount"`
|
||||
WindowStart string `json:"windowStart"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
// Endpoint operations
|
||||
CreateEndpoint(ctx context.Context, url string) (*Endpoint, error)
|
||||
GetEndpoint(ctx context.Context, id string) (*Endpoint, error)
|
||||
AppendRequest(ctx context.Context, endpointID string, req *Request) error
|
||||
@ -37,6 +80,29 @@ type Repository interface {
|
||||
SearchRequests(ctx context.Context, endpointID string, query string, limit int) ([]*Request, error)
|
||||
GetMock(ctx context.Context, endpointID string) (*MockConfig, error)
|
||||
SetMock(ctx context.Context, mock *MockConfig) error
|
||||
|
||||
// Space operations
|
||||
CreateSpace(ctx context.Context, space *Space) (*Space, error)
|
||||
GetSpaceBySlug(ctx context.Context, slug string) (*Space, error)
|
||||
UpdateSpace(ctx context.Context, space *Space) (*Space, error)
|
||||
DeleteSpace(ctx context.Context, id string) error
|
||||
ListPublicSpaces(ctx context.Context) ([]*Space, error)
|
||||
|
||||
// Photo operations
|
||||
CreatePhoto(ctx context.Context, photo *Photo) (*Photo, error)
|
||||
GetPhoto(ctx context.Context, id string) (*Photo, error)
|
||||
ListPhotosBySpace(ctx context.Context, spaceID string, limit int) ([]*Photo, error)
|
||||
DeletePhoto(ctx context.Context, id string) error
|
||||
|
||||
// Face embedding operations
|
||||
CreateFaceEmbedding(ctx context.Context, embedding *FaceEmbedding) (*FaceEmbedding, error)
|
||||
ListFaceEmbeddingsBySpace(ctx context.Context, spaceID string) ([]*FaceEmbedding, error)
|
||||
SearchFaceEmbeddings(ctx context.Context, spaceID string, embedding []float32, limit int) ([]*FaceEmbedding, error)
|
||||
|
||||
// Rate limiting operations
|
||||
CheckRateLimit(ctx context.Context, ipAddress, spaceID string, limit, windowMinutes int) (bool, error)
|
||||
IncrementRateLimit(ctx context.Context, ipAddress, spaceID string, windowMinutes int) error
|
||||
|
||||
Close() error
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
|
||||
51
internal/store/schema_spaces.sql
Normal file
51
internal/store/schema_spaces.sql
Normal file
@ -0,0 +1,51 @@
|
||||
-- Space tables for public demo spaces with face search
|
||||
|
||||
CREATE TABLE IF NOT EXISTS spaces (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_public BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_spaces_slug ON spaces(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_spaces_is_public ON spaces(is_public);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photos (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
thumbnail_url TEXT,
|
||||
face_count INTEGER NOT NULL DEFAULT 0,
|
||||
metadata TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_space_id ON photos(space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_created_at ON photos(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS face_embeddings (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
embedding BLOB NOT NULL, -- JSON array of float32
|
||||
bounding_box TEXT NOT NULL, -- JSON bounding box
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_face_embeddings_space_id ON face_embeddings(space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_face_embeddings_photo_id ON face_embeddings(photo_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
ip_address TEXT NOT NULL,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
request_count INTEGER NOT NULL DEFAULT 1,
|
||||
window_start TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_limits_ip_space ON rate_limits(ip_address, space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_limits_window_start ON rate_limits(window_start);
|
||||
248
internal/store/sqlite_repo_spaces.go
Normal file
248
internal/store/sqlite_repo_spaces.go
Normal file
@ -0,0 +1,248 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Space operations
|
||||
|
||||
func (r *sqliteRepo) CreateSpace(ctx context.Context, space *Space) (*Space, error) {
|
||||
now := utcNow()
|
||||
space.ID = uuid.NewString()
|
||||
space.CreatedAt = now
|
||||
space.UpdatedAt = now
|
||||
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO spaces (id, slug, name, description, is_public, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
space.ID, space.Slug, space.Name, space.Description, space.IsPublic, space.CreatedAt, space.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: create space: %w", err)
|
||||
}
|
||||
return space, nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) GetSpaceBySlug(ctx context.Context, slug string) (*Space, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, slug, name, description, is_public, created_at, updated_at FROM spaces WHERE slug = ?`, slug)
|
||||
space := &Space{}
|
||||
if err := row.Scan(&space.ID, &space.Slug, &space.Name, &space.Description, &space.IsPublic, &space.CreatedAt, &space.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: get space by slug: %w", err)
|
||||
}
|
||||
return space, nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) UpdateSpace(ctx context.Context, space *Space) (*Space, error) {
|
||||
space.UpdatedAt = utcNow()
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE spaces SET name = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?`,
|
||||
space.Name, space.Description, space.IsPublic, space.UpdatedAt, space.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: update space: %w", err)
|
||||
}
|
||||
return space, nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) DeleteSpace(ctx context.Context, id string) error {
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM spaces WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete space: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) ListPublicSpaces(ctx context.Context) ([]*Space, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, slug, name, description, is_public, created_at, updated_at FROM spaces WHERE is_public = TRUE ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list public spaces: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var spaces []*Space
|
||||
for rows.Next() {
|
||||
space := &Space{}
|
||||
if err := rows.Scan(&space.ID, &space.Slug, &space.Name, &space.Description, &space.IsPublic, &space.CreatedAt, &space.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan space: %w", err)
|
||||
}
|
||||
spaces = append(spaces, space)
|
||||
}
|
||||
return spaces, rows.Err()
|
||||
}
|
||||
|
||||
// Photo operations
|
||||
|
||||
func (r *sqliteRepo) CreatePhoto(ctx context.Context, photo *Photo) (*Photo, error) {
|
||||
photo.ID = uuid.NewString()
|
||||
photo.CreatedAt = utcNow()
|
||||
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO photos (id, space_id, url, thumbnail_url, face_count, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
photo.ID, photo.SpaceID, photo.URL, photo.ThumbnailURL, photo.FaceCount, photo.Metadata, photo.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: create photo: %w", err)
|
||||
}
|
||||
return photo, nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) GetPhoto(ctx context.Context, id string) (*Photo, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, space_id, url, thumbnail_url, face_count, metadata, created_at FROM photos WHERE id = ?`, id)
|
||||
photo := &Photo{}
|
||||
if err := row.Scan(&photo.ID, &photo.SpaceID, &photo.URL, &photo.ThumbnailURL, &photo.FaceCount, &photo.Metadata, &photo.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: get photo: %w", err)
|
||||
}
|
||||
return photo, nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) ListPhotosBySpace(ctx context.Context, spaceID string, limit int) ([]*Photo, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, space_id, url, thumbnail_url, face_count, metadata, created_at FROM photos WHERE space_id = ? ORDER BY created_at DESC LIMIT ?`,
|
||||
spaceID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list photos by space: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var photos []*Photo
|
||||
for rows.Next() {
|
||||
photo := &Photo{}
|
||||
if err := rows.Scan(&photo.ID, &photo.SpaceID, &photo.URL, &photo.ThumbnailURL, &photo.FaceCount, &photo.Metadata, &photo.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan photo: %w", err)
|
||||
}
|
||||
photos = append(photos, photo)
|
||||
}
|
||||
return photos, rows.Err()
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) DeletePhoto(ctx context.Context, id string) error {
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM photos WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete photo: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Face embedding operations
|
||||
|
||||
func (r *sqliteRepo) CreateFaceEmbedding(ctx context.Context, embedding *FaceEmbedding) (*FaceEmbedding, error) {
|
||||
embedding.ID = uuid.NewString()
|
||||
embedding.CreatedAt = utcNow()
|
||||
|
||||
// Convert embedding to JSON
|
||||
embeddingJSON, err := json.Marshal(embedding.Embedding)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: marshal face embedding: %w", err)
|
||||
}
|
||||
|
||||
_, err = r.db.ExecContext(ctx,
|
||||
`INSERT INTO face_embeddings (id, photo_id, space_id, embedding, bounding_box, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
embedding.ID, embedding.PhotoID, embedding.SpaceID, embeddingJSON, embedding.BoundingBox, embedding.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: create face embedding: %w", err)
|
||||
}
|
||||
return embedding, nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) ListFaceEmbeddingsBySpace(ctx context.Context, spaceID string) ([]*FaceEmbedding, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, photo_id, space_id, embedding, bounding_box, created_at FROM face_embeddings WHERE space_id = ?`,
|
||||
spaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list face embeddings by space: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var embeddings []*FaceEmbedding
|
||||
for rows.Next() {
|
||||
embedding := &FaceEmbedding{}
|
||||
var embeddingJSON []byte
|
||||
if err := rows.Scan(&embedding.ID, &embedding.PhotoID, &embedding.SpaceID, &embeddingJSON, &embedding.BoundingBox, &embedding.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan face embedding: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(embeddingJSON, &embedding.Embedding); err != nil {
|
||||
return nil, fmt.Errorf("store: unmarshal face embedding: %w", err)
|
||||
}
|
||||
embeddings = append(embeddings, embedding)
|
||||
}
|
||||
return embeddings, rows.Err()
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) SearchFaceEmbeddings(ctx context.Context, spaceID string, embedding []float32, limit int) ([]*FaceEmbedding, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
// For now, return all embeddings in the space (cosine similarity search would be implemented here)
|
||||
// In a real implementation, you'd compute cosine similarity between the query embedding and all embeddings
|
||||
embeddings, err := r.ListFaceEmbeddingsBySpace(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Return up to limit results
|
||||
if len(embeddings) > limit {
|
||||
embeddings = embeddings[:limit]
|
||||
}
|
||||
return embeddings, nil
|
||||
}
|
||||
|
||||
// Rate limiting operations
|
||||
|
||||
func (r *sqliteRepo) CheckRateLimit(ctx context.Context, ipAddress, spaceID string, limit, windowMinutes int) (bool, error) {
|
||||
// Calculate window start time
|
||||
windowStart := time.Now().UTC().Add(-time.Duration(windowMinutes) * time.Minute).Format("2006-01-02T15:04:05.000Z07:00")
|
||||
|
||||
var count int
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(SUM(request_count), 0) FROM rate_limits WHERE ip_address = ? AND space_id = ? AND window_start >= ?`,
|
||||
ipAddress, spaceID, windowStart).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("store: check rate limit: %w", err)
|
||||
}
|
||||
|
||||
return count < limit, nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) IncrementRateLimit(ctx context.Context, ipAddress, spaceID string, windowMinutes int) error {
|
||||
// Calculate window start time (rounded to the nearest window)
|
||||
now := time.Now().UTC()
|
||||
windowStart := now.Add(-time.Duration(now.Minute()%windowMinutes) * time.Minute).Format("2006-01-02T15:04:05.000Z07:00")
|
||||
|
||||
// Check if there's an existing record for this IP, space, and window
|
||||
var id string
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
`SELECT id FROM rate_limits WHERE ip_address = ? AND space_id = ? AND window_start = ?`,
|
||||
ipAddress, spaceID, windowStart).Scan(&id)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
// Create new record
|
||||
id = uuid.NewString()
|
||||
_, err = r.db.ExecContext(ctx,
|
||||
`INSERT INTO rate_limits (id, ip_address, space_id, request_count, window_start, created_at) VALUES (?, ?, ?, 1, ?, ?)`,
|
||||
id, ipAddress, spaceID, windowStart, utcNow())
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: create rate limit: %w", err)
|
||||
}
|
||||
} else if err == nil {
|
||||
// Update existing record
|
||||
_, err = r.db.ExecContext(ctx,
|
||||
`UPDATE rate_limits SET request_count = request_count + 1 WHERE id = ?`,
|
||||
id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: increment rate limit: %w", err)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("store: get rate limit: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -12,13 +12,23 @@ type FakeRepository struct {
|
||||
Endpoints map[string]*store.Endpoint
|
||||
Requests []*store.Request
|
||||
Mocks map[string]*store.MockConfig
|
||||
|
||||
// Space-related fields
|
||||
Spaces map[string]*store.Space
|
||||
Photos map[string]*store.Photo
|
||||
FaceEmbeddings map[string]*store.FaceEmbedding
|
||||
RateLimits map[string]*store.RateLimit
|
||||
}
|
||||
|
||||
// NewFakeRepository creates a new FakeRepository.
|
||||
func NewFakeRepository() *FakeRepository {
|
||||
return &FakeRepository{
|
||||
Endpoints: map[string]*store.Endpoint{},
|
||||
Mocks: map[string]*store.MockConfig{},
|
||||
Endpoints: map[string]*store.Endpoint{},
|
||||
Mocks: map[string]*store.MockConfig{},
|
||||
Spaces: map[string]*store.Space{},
|
||||
Photos: map[string]*store.Photo{},
|
||||
FaceEmbeddings: map[string]*store.FaceEmbedding{},
|
||||
RateLimits: map[string]*store.RateLimit{},
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,6 +103,118 @@ func (f *FakeRepository) Close() error { return nil }
|
||||
|
||||
func (f *FakeRepository) Ping(_ context.Context) error { return nil }
|
||||
|
||||
// Space operations
|
||||
|
||||
func (f *FakeRepository) CreateSpace(_ context.Context, space *store.Space) (*store.Space, error) {
|
||||
f.Spaces[space.Slug] = space
|
||||
return space, nil
|
||||
}
|
||||
|
||||
func (f *FakeRepository) GetSpaceBySlug(_ context.Context, slug string) (*store.Space, error) {
|
||||
space, ok := f.Spaces[slug]
|
||||
if !ok {
|
||||
return nil, errNotFound
|
||||
}
|
||||
return space, nil
|
||||
}
|
||||
|
||||
func (f *FakeRepository) UpdateSpace(_ context.Context, space *store.Space) (*store.Space, error) {
|
||||
f.Spaces[space.Slug] = space
|
||||
return space, nil
|
||||
}
|
||||
|
||||
func (f *FakeRepository) DeleteSpace(_ context.Context, id string) error {
|
||||
for slug, space := range f.Spaces {
|
||||
if space.ID == id {
|
||||
delete(f.Spaces, slug)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errNotFound
|
||||
}
|
||||
|
||||
func (f *FakeRepository) ListPublicSpaces(_ context.Context) ([]*store.Space, error) {
|
||||
var spaces []*store.Space
|
||||
for _, space := range f.Spaces {
|
||||
if space.IsPublic {
|
||||
spaces = append(spaces, space)
|
||||
}
|
||||
}
|
||||
return spaces, nil
|
||||
}
|
||||
|
||||
// Photo operations
|
||||
|
||||
func (f *FakeRepository) CreatePhoto(_ context.Context, photo *store.Photo) (*store.Photo, error) {
|
||||
f.Photos[photo.ID] = photo
|
||||
return photo, nil
|
||||
}
|
||||
|
||||
func (f *FakeRepository) GetPhoto(_ context.Context, id string) (*store.Photo, error) {
|
||||
photo, ok := f.Photos[id]
|
||||
if !ok {
|
||||
return nil, errNotFound
|
||||
}
|
||||
return photo, nil
|
||||
}
|
||||
|
||||
func (f *FakeRepository) ListPhotosBySpace(_ context.Context, spaceID string, limit int) ([]*store.Photo, error) {
|
||||
var photos []*store.Photo
|
||||
for _, photo := range f.Photos {
|
||||
if photo.SpaceID == spaceID {
|
||||
photos = append(photos, photo)
|
||||
if limit > 0 && len(photos) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return photos, nil
|
||||
}
|
||||
|
||||
func (f *FakeRepository) DeletePhoto(_ context.Context, id string) error {
|
||||
for photoID := range f.Photos {
|
||||
if photoID == id {
|
||||
delete(f.Photos, photoID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errNotFound
|
||||
}
|
||||
|
||||
// Face embedding operations
|
||||
|
||||
func (f *FakeRepository) CreateFaceEmbedding(_ context.Context, embedding *store.FaceEmbedding) (*store.FaceEmbedding, error) {
|
||||
f.FaceEmbeddings[embedding.ID] = embedding
|
||||
return embedding, nil
|
||||
}
|
||||
|
||||
func (f *FakeRepository) ListFaceEmbeddingsBySpace(_ context.Context, spaceID string) ([]*store.FaceEmbedding, error) {
|
||||
var embeddings []*store.FaceEmbedding
|
||||
for _, embedding := range f.FaceEmbeddings {
|
||||
if embedding.SpaceID == spaceID {
|
||||
embeddings = append(embeddings, embedding)
|
||||
}
|
||||
}
|
||||
return embeddings, nil
|
||||
}
|
||||
|
||||
func (f *FakeRepository) SearchFaceEmbeddings(_ context.Context, spaceID string, embedding []float32, limit int) ([]*store.FaceEmbedding, error) {
|
||||
// For fake repo, just return all embeddings in the space
|
||||
return f.ListFaceEmbeddingsBySpace(nil, spaceID)
|
||||
}
|
||||
|
||||
// Rate limiting operations
|
||||
|
||||
func (f *FakeRepository) CheckRateLimit(_ context.Context, ipAddress, spaceID string, limit, windowMinutes int) (bool, error) {
|
||||
// For fake repo, always allow
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *FakeRepository) IncrementRateLimit(_ context.Context, ipAddress, spaceID string, windowMinutes int) error {
|
||||
// For fake repo, do nothing
|
||||
return nil
|
||||
}
|
||||
|
||||
type notFoundError struct{}
|
||||
|
||||
func (e *notFoundError) Error() string { return "not found" }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user