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 {
|
type Handler struct {
|
||||||
Repo store.Repository
|
Repo store.Repository
|
||||||
Debug bool
|
Debug bool
|
||||||
|
FaceService *face.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handler with the given store.
|
// New creates a new Handler with the given store.
|
||||||
@ -23,6 +24,14 @@ func New(repo store.Repository) *Handler {
|
|||||||
return &Handler{Repo: repo}
|
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.
|
// RegisterRoutes mounts all routes on the given chi router.
|
||||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
@ -46,6 +55,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
// JSON API v1 routes.
|
// JSON API v1 routes.
|
||||||
h.RegisterV1Routes(r)
|
h.RegisterV1Routes(r)
|
||||||
|
|
||||||
|
// Space management routes.
|
||||||
|
h.RegisterSpaceRoutes(r)
|
||||||
|
|
||||||
// Inspect page: server-rendered request list.
|
// Inspect page: server-rendered request list.
|
||||||
r.Get("/e/{endpointID}", HandleInspect(h.Repo))
|
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
|
//go:embed schema.sql
|
||||||
var schemaSQL string
|
var schemaSQL string
|
||||||
|
|
||||||
|
//go:embed schema_spaces.sql
|
||||||
|
var schemaSpacesSQL string
|
||||||
|
|
||||||
func Open(dbPath string) (Repository, error) {
|
func Open(dbPath string) (Repository, error) {
|
||||||
if dbPath == "" {
|
if dbPath == "" {
|
||||||
dbPath = filepath.Join("data", "hatch.db")
|
dbPath = filepath.Join("data", "hatch.db")
|
||||||
@ -39,9 +42,17 @@ func Open(dbPath string) (Repository, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrate(db *sql.DB) error {
|
func migrate(db *sql.DB) error {
|
||||||
|
// Load main schema
|
||||||
_, err := db.Exec(schemaSQL)
|
_, err := db.Exec(schemaSQL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("store/migrate: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,50 @@ type MockConfig struct {
|
|||||||
Body []byte `json:"body"`
|
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 {
|
type Repository interface {
|
||||||
|
// Endpoint operations
|
||||||
CreateEndpoint(ctx context.Context, url string) (*Endpoint, error)
|
CreateEndpoint(ctx context.Context, url string) (*Endpoint, error)
|
||||||
GetEndpoint(ctx context.Context, id string) (*Endpoint, error)
|
GetEndpoint(ctx context.Context, id string) (*Endpoint, error)
|
||||||
AppendRequest(ctx context.Context, endpointID string, req *Request) 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)
|
SearchRequests(ctx context.Context, endpointID string, query string, limit int) ([]*Request, error)
|
||||||
GetMock(ctx context.Context, endpointID string) (*MockConfig, error)
|
GetMock(ctx context.Context, endpointID string) (*MockConfig, error)
|
||||||
SetMock(ctx context.Context, mock *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
|
Close() error
|
||||||
Ping(ctx context.Context) 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
|
Endpoints map[string]*store.Endpoint
|
||||||
Requests []*store.Request
|
Requests []*store.Request
|
||||||
Mocks map[string]*store.MockConfig
|
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.
|
// NewFakeRepository creates a new FakeRepository.
|
||||||
func NewFakeRepository() *FakeRepository {
|
func NewFakeRepository() *FakeRepository {
|
||||||
return &FakeRepository{
|
return &FakeRepository{
|
||||||
Endpoints: map[string]*store.Endpoint{},
|
Endpoints: map[string]*store.Endpoint{},
|
||||||
Mocks: map[string]*store.MockConfig{},
|
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 }
|
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{}
|
type notFoundError struct{}
|
||||||
|
|
||||||
func (e *notFoundError) Error() string { return "not found" }
|
func (e *notFoundError) Error() string { return "not found" }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user