Add face service package for AWS Rekognition integration

- Client interface and MockClient for testing
- Service layer with IndexPhoto and SearchFaces methods
- CacheService interface and MemoryCache implementation
- Configuration with environment variable support
- Error types and user-facing messages
- Unit tests for service layer

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Chris Anderson 2026-06-25 21:11:45 +02:00
parent 6e472f3eea
commit 4bf37faf4f
8 changed files with 1328 additions and 0 deletions

153
internal/face/cache.go Normal file
View File

@ -0,0 +1,153 @@
package face
import (
"context"
"encoding/json"
"sync"
"time"
)
// CacheService defines the interface for caching face search results
type CacheService interface {
// Get retrieves a cached value by key
Get(ctx context.Context, key string) (string, error)
// Set stores a value with expiration
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
// Delete removes cached values
Delete(ctx context.Context, keys ...string) error
// GetJSON retrieves and unmarshals a cached JSON value
GetJSON(ctx context.Context, key string, dest interface{}) error
// SetJSON marshals and stores a JSON value
SetJSON(ctx context.Context, key string, value interface{}, expiration time.Duration) error
}
// MemoryCache is an in-memory cache implementation for testing and development
type MemoryCache struct {
mu sync.RWMutex
entries map[string]*cacheEntry
}
type cacheEntry struct {
value string
expiresAt time.Time
}
// NewMemoryCache creates a new in-memory cache
func NewMemoryCache() *MemoryCache {
return &MemoryCache{
entries: make(map[string]*cacheEntry),
}
}
// Get retrieves a value from the cache
func (c *MemoryCache) Get(ctx context.Context, key string) (string, error) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.entries[key]
if !ok {
return "", nil
}
if time.Now().After(entry.expiresAt) {
return "", nil
}
return entry.value, nil
}
// Set stores a value in the cache
func (c *MemoryCache) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
var strValue string
switch v := value.(type) {
case string:
strValue = v
default:
bytes, err := json.Marshal(v)
if err != nil {
return err
}
strValue = string(bytes)
}
c.entries[key] = &cacheEntry{
value: strValue,
expiresAt: time.Now().Add(expiration),
}
return nil
}
// Delete removes values from the cache
func (c *MemoryCache) Delete(ctx context.Context, keys ...string) error {
c.mu.Lock()
defer c.mu.Unlock()
for _, key := range keys {
delete(c.entries, key)
}
return nil
}
// GetJSON retrieves and unmarshals a cached JSON value
func (c *MemoryCache) GetJSON(ctx context.Context, key string, dest interface{}) error {
value, err := c.Get(ctx, key)
if err != nil {
return err
}
if value == "" {
return nil
}
return json.Unmarshal([]byte(value), dest)
}
// SetJSON marshals and stores a JSON value
func (c *MemoryCache) SetJSON(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
return c.Set(ctx, key, value, expiration)
}
// Cleanup removes expired entries (call periodically)
func (c *MemoryCache) Cleanup() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for key, entry := range c.entries {
if now.After(entry.expiresAt) {
delete(c.entries, key)
}
}
}
// CacheKeys returns common cache key patterns
type CacheKeys struct{}
// Search returns the cache key for search results
func (CacheKeys) Search(spaceID string, imageHash string) string {
return "search:" + spaceID + ":" + imageHash
}
// Photo returns the cache key for photo metadata
func (CacheKeys) Photo(photoID string) string {
return "photo:" + photoID
}
// Face returns the cache key for face embeddings
func (CacheKeys) Face(faceID string) string {
return "face:" + faceID
}
// Indexed returns the cache key for indexed photos
func (CacheKeys) Indexed(photoID string) string {
return "indexed:" + photoID
}

191
internal/face/client.go Normal file
View File

@ -0,0 +1,191 @@
package face
import (
"context"
"fmt"
"sync"
"time"
)
// Client defines the interface for face detection, indexing, and search operations
type Client interface {
// CreateCollection creates a new face collection for a space
CreateCollection(ctx context.Context, collectionName string) error
// DeleteCollection deletes a face collection
DeleteCollection(ctx context.Context, collectionName string) error
// DetectFaces detects faces in an image
DetectFaces(ctx context.Context, imageBytes []byte) ([]FaceDetection, error)
// IndexFace indexes a face in a collection
IndexFace(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error)
// SearchFacesByImage searches for similar faces in the collection
SearchFacesByImage(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error)
}
// Compile-time check that RekognitionClient implements Client
var _ Client = (*RekognitionClient)(nil)
// MockClient is a mock implementation of the Client interface for testing
type MockClient struct {
mu sync.RWMutex
collections map[string]*CollectionInfo
faces map[string][]FaceIndex
detections []FaceDetection
searchResults []FaceMatch
// Mock configuration
DetectFacesFunc func(ctx context.Context, imageBytes []byte) ([]FaceDetection, error)
IndexFaceFunc func(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error)
SearchFacesFunc func(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error)
}
// FaceIndex represents an indexed face (internal to mock)
type FaceIndex struct {
FaceID string `json:"faceId"`
ExternalImageID string `json:"externalImageId"`
Confidence float64 `json:"confidence"`
BoundingBox BoundingBox `json:"boundingBox"`
IndexedAt time.Time `json:"indexedAt"`
}
// NewMockClient creates a new mock client for testing
func NewMockClient() *MockClient {
return &MockClient{
collections: make(map[string]*CollectionInfo),
faces: make(map[string][]FaceIndex),
}
}
// CreateCollection creates a mock collection
func (m *MockClient) CreateCollection(ctx context.Context, collectionName string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.collections[collectionName] = &CollectionInfo{
CollectionID: collectionName,
FaceCount: 0,
CreationDate: time.Now(),
LastUpdated: time.Now(),
}
return nil
}
// DeleteCollection deletes a mock collection
func (m *MockClient) DeleteCollection(ctx context.Context, collectionName string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.collections, collectionName)
delete(m.faces, collectionName)
return nil
}
// DetectFaces returns mock face detections
func (m *MockClient) DetectFaces(ctx context.Context, imageBytes []byte) ([]FaceDetection, error) {
if m.DetectFacesFunc != nil {
return m.DetectFacesFunc(ctx, imageBytes)
}
m.mu.RLock()
defer m.mu.RUnlock()
// Return mock detections
if len(m.detections) > 0 {
return m.detections, nil
}
// Default: return one face
return []FaceDetection{
{
Confidence: 99.5,
BoundingBox: BoundingBox{
Left: 0.1,
Top: 0.1,
Width: 0.3,
Height: 0.3,
},
},
}, nil
}
// IndexFace indexes a mock face
func (m *MockClient) IndexFace(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error) {
if m.IndexFaceFunc != nil {
return m.IndexFaceFunc(ctx, imageBytes, photoID)
}
m.mu.Lock()
defer m.mu.Unlock()
detection := &FaceDetection{
Confidence: 99.5,
BoundingBox: BoundingBox{
Left: 0.1,
Top: 0.1,
Width: 0.3,
Height: 0.3,
},
}
// Store in mock database
faceIndex := FaceIndex{
FaceID: fmt.Sprintf("face-%s-%d", photoID, time.Now().UnixNano()),
ExternalImageID: photoID,
Confidence: 99.5,
BoundingBox: detection.BoundingBox,
IndexedAt: time.Now(),
}
m.faces["hatch-default"] = append(m.faces["hatch-default"], faceIndex)
return detection, nil
}
// SearchFacesByImage returns mock search results
func (m *MockClient) SearchFacesByImage(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error) {
if m.SearchFacesFunc != nil {
return m.SearchFacesFunc(ctx, imageBytes, maxFaces, threshold)
}
m.mu.RLock()
defer m.mu.RUnlock()
// Return mock matches
if len(m.searchResults) > 0 {
if maxFaces > 0 && len(m.searchResults) > maxFaces {
return m.searchResults[:maxFaces], nil
}
return m.searchResults, nil
}
// Default: return one match
return []FaceMatch{
{
FaceID: "face-mock-1",
ExternalImageID: "photo-1",
Similarity: 95.0,
BoundingBox: BoundingBox{
Left: 0.1,
Top: 0.1,
Width: 0.3,
Height: 0.3,
},
},
}, nil
}
// SetDetections configures mock face detections
func (m *MockClient) SetDetections(detections []FaceDetection) {
m.mu.Lock()
defer m.mu.Unlock()
m.detections = detections
}
// SetSearchResults configures mock search results
func (m *MockClient) SetSearchResults(results []FaceMatch) {
m.mu.Lock()
defer m.mu.Unlock()
m.searchResults = results
}

102
internal/face/config.go Normal file
View File

@ -0,0 +1,102 @@
package face
import (
"os"
"strconv"
"time"
)
// Config holds configuration for the face service
type Config struct {
// AWS Configuration
Region string
CollectionID string
// Face Detection Configuration
MaxFaces int
Threshold float64
QualityFilter string
// Cache Configuration
CacheTTL time.Duration
SearchCacheTTL time.Duration
PhotoCacheTTL time.Duration
// Rate Limiting
GuestRateLimit int
GuestRateWindowMin int
AuthRateLimit int
AuthRateWindowMin int
// Image Optimization
MaxImageSize int
ResizeWidth int
ResizeHeight int
ImageQuality int
}
// DefaultConfig returns default configuration
func DefaultConfig() *Config {
return &Config{
Region: getEnv("AWS_REGION", "us-east-1"),
CollectionID: getEnv("REKOGNITION_COLLECTION", "hatch-faces"),
MaxFaces: getEnvInt("REKOGNITION_MAX_FACES", 10),
Threshold: getEnvFloat("REKOGNITION_THRESHOLD", 80.0),
QualityFilter: getEnv("REKOGNITION_QUALITY_FILTER", "AUTO"),
CacheTTL: 5 * time.Minute,
SearchCacheTTL: 5 * time.Minute,
PhotoCacheTTL: 24 * time.Hour,
GuestRateLimit: getEnvInt("RATE_LIMIT_GUEST_SEARCHES", 20),
GuestRateWindowMin: getEnvInt("RATE_LIMIT_GUEST_WINDOW_MINUTES", 15),
AuthRateLimit: getEnvInt("RATE_LIMIT_AUTH_SEARCHES", 100),
AuthRateWindowMin: getEnvInt("RATE_LIMIT_AUTH_WINDOW_MINUTES", 1),
MaxImageSize: 10 * 1024 * 1024, // 10MB
ResizeWidth: 1024,
ResizeHeight: 1024,
ImageQuality: 85,
}
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
if c.Region == "" {
return ErrMissingRegion
}
if c.CollectionID == "" {
return ErrMissingCollectionID
}
if c.MaxFaces <= 0 {
return ErrInvalidMaxFaces
}
if c.Threshold < 0 || c.Threshold > 100 {
return ErrInvalidThreshold
}
return nil
}
// Helper functions for environment variables
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getEnvInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
if intValue, err := strconv.Atoi(value); err == nil {
return intValue
}
}
return defaultValue
}
func getEnvFloat(key string, defaultValue float64) float64 {
if value := os.Getenv(key); value != "" {
if floatValue, err := strconv.ParseFloat(value, 64); err == nil {
return floatValue
}
}
return defaultValue
}

94
internal/face/errors.go Normal file
View File

@ -0,0 +1,94 @@
package face
import "errors"
// Sentinel errors for face operations
var (
// Client errors
ErrNoFacesDetected = errors.New("no faces detected in image")
ErrImageTooSmall = errors.New("image too small for face detection")
ErrImageTooLarge = errors.New("image exceeds maximum size")
ErrInvalidImageFormat = errors.New("invalid image format")
ErrImageProcessingFailed = errors.New("failed to process image")
// Collection errors
ErrCollectionNotFound = errors.New("face collection not found")
ErrCollectionExists = errors.New("face collection already exists")
ErrCollectionCreationFailed = errors.New("failed to create face collection")
// Indexing errors
ErrIndexingFailed = errors.New("failed to index face")
ErrFaceAlreadyIndexed = errors.New("face already indexed")
// Search errors
ErrSearchFailed = errors.New("face search failed")
ErrNoMatchesFound = errors.New("no matching faces found")
// Configuration errors
ErrMissingRegion = errors.New("AWS region is required")
ErrMissingCollectionID = errors.New("collection ID is required")
ErrInvalidMaxFaces = errors.New("max faces must be positive")
ErrInvalidThreshold = errors.New("threshold must be between 0 and 100")
// Rate limiting errors
ErrRateLimitExceeded = errors.New("rate limit exceeded")
// AWS service errors
ErrAWSServiceError = errors.New("AWS Rekognition service error")
ErrAWSAccessDenied = errors.New("AWS access denied")
ErrAWSThrottling = errors.New("AWS request throttled")
ErrAWSInvalidImage = errors.New("invalid image for AWS Rekognition")
)
// FaceSearchError represents a structured error for face search operations
type FaceSearchError struct {
Code string `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
}
// Error implements the error interface
func (e *FaceSearchError) Error() string {
return e.Message
}
// NewFaceSearchError creates a new FaceSearchError
func NewFaceSearchError(code, message, details string) *FaceSearchError {
return &FaceSearchError{
Code: code,
Message: message,
Details: details,
}
}
// Common error codes
const (
ErrorCodeNoFaces = "NO_FACES_DETECTED"
ErrorCodeInvalidImage = "INVALID_IMAGE"
ErrorCodeRateLimited = "RATE_LIMITED"
ErrorCodeCollectionError = "COLLECTION_ERROR"
ErrorCodeSearchFailed = "SEARCH_FAILED"
ErrorCodeIndexingFailed = "INDEXING_FAILED"
ErrorCodeInternalError = "INTERNAL_ERROR"
)
// User-facing error messages
var UserErrorMessages = map[error]string{
ErrNoFacesDetected: "No faces detected in the image. Please upload a photo with clear faces.",
ErrImageTooSmall: "Image is too small. Please upload a larger image (minimum 100x100 pixels).",
ErrImageTooLarge: "Image is too large. Please upload an image smaller than 10MB.",
ErrInvalidImageFormat: "Invalid image format. Please upload a JPEG, PNG, or WebP image.",
ErrCollectionNotFound: "Face collection not found. Please contact support.",
ErrRateLimitExceeded: "Search limit exceeded. Please try again later.",
ErrAWSServiceError: "An error occurred while processing your request. Please try again.",
ErrAWSAccessDenied: "Access denied. Please contact support.",
ErrAWSThrottling: "Service is busy. Please try again in a few moments.",
}
// GetUserMessage returns a user-friendly error message
func GetUserMessage(err error) string {
if msg, ok := UserErrorMessages[err]; ok {
return msg
}
return "An unexpected error occurred. Please try again."
}

35
internal/face/models.go Normal file
View File

@ -0,0 +1,35 @@
package face
import "time"
// FaceSearchResult represents the result of a face search
type FaceSearchResult struct {
Matches []FaceMatch `json:"matches"`
PhotoCount int `json:"photoCount"`
SearchTime time.Duration `json:"searchTime"`
CacheHit bool `json:"cacheHit"`
}
// CollectionInfo represents information about a face collection
type CollectionInfo struct {
CollectionID string `json:"collectionId"`
FaceCount int `json:"faceCount"`
CreationDate time.Time `json:"creationDate"`
LastUpdated time.Time `json:"lastUpdated"`
}
// SearchRequest represents a face search request
type SearchRequest struct {
ImageBytes []byte `json:"-"`
ImageURL string `json:"imageUrl,omitempty"`
MaxFaces int `json:"maxFaces,omitempty"`
Threshold float64 `json:"threshold,omitempty"`
}
// IndexRequest represents a face indexing request
type IndexRequest struct {
PhotoID string `json:"photoId"`
SpaceID string `json:"spaceId"`
ImageBytes []byte `json:"-"`
ImageURL string `json:"imageUrl,omitempty"`
}

View File

@ -0,0 +1,218 @@
package face
import (
"context"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"bytes"
"io"
"mime/multipart"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/rekognition"
"github.com/aws/aws-sdk-go-v2/service/rekognition/types"
)
// RekognitionClient wraps AWS Rekognition operations
type RekognitionClient struct {
client *rekognition.Client
collectionID string
}
// FaceDetection represents a detected face
type FaceDetection struct {
Confidence float64 `json:"confidence"`
BoundingBox BoundingBox `json:"boundingBox"`
}
// BoundingBox represents face location in image
type BoundingBox struct {
Left float64 `json:"left"`
Top float64 `json:"top"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
// FaceMatch represents a matched face
type FaceMatch struct {
FaceID string `json:"faceId"`
ExternalImageID string `json:"externalImageId"`
Similarity float64 `json:"similarity"`
BoundingBox BoundingBox `json:"boundingBox"`
PhotoID string `json:"photoId,omitempty"`
PhotoURL string `json:"photoUrl,omitempty"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
}
// NewRekognitionClient creates a new Rekognition client
func NewRekognitionClient(cfg aws.Config, collectionID string) *RekognitionClient {
return &RekognitionClient{
client: rekognition.NewFromConfig(cfg),
collectionID: collectionID,
}
}
// CreateCollection creates a new face collection
func (r *RekognitionClient) CreateCollection(ctx context.Context, collectionName string) error {
_, err := r.client.CreateCollection(ctx, &rekognition.CreateCollectionInput{
CollectionId: &collectionName,
})
if err != nil {
return fmt.Errorf("failed to create collection: %w", err)
}
return nil
}
// DeleteCollection deletes a face collection
func (r *RekognitionClient) DeleteCollection(ctx context.Context, collectionName string) error {
_, err := r.client.DeleteCollection(ctx, &rekognition.DeleteCollectionInput{
CollectionId: &collectionName,
})
if err != nil {
return fmt.Errorf("failed to delete collection: %w", err)
}
return nil
}
// DetectFaces detects faces in an image
func (r *RekognitionClient) DetectFaces(ctx context.Context, imageBytes []byte) ([]FaceDetection, error) {
input := &rekognition.DetectFacesInput{
Image: &types.Image{
Bytes: imageBytes,
},
Attributes: []types.Attribute{
types.AttributeAll,
},
}
result, err := r.client.DetectFaces(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to detect faces: %w", err)
}
var detections []FaceDetection
for _, face := range result.FaceDetails {
if face.BoundingBox == nil {
continue
}
detection := FaceDetection{
Confidence: float64(*face.Confidence),
BoundingBox: BoundingBox{
Left: float64(*face.BoundingBox.Left),
Top: float64(*face.BoundingBox.Top),
Width: float64(*face.BoundingBox.Width),
Height: float64(*face.BoundingBox.Height),
},
}
detections = append(detections, detection)
}
return detections, nil
}
// IndexFace indexes a face in the collection
func (r *RekognitionClient) IndexFace(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error) {
input := &rekognition.IndexFacesInput{
CollectionId: &r.collectionID,
Image: &types.Image{
Bytes: imageBytes,
},
ExternalImageId: &photoID,
MaxFaces: aws.Int32(1),
QualityFilter: types.QualityFilterAuto,
}
result, err := r.client.IndexFaces(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to index face: %w", err)
}
if len(result.FaceRecords) == 0 {
return nil, fmt.Errorf("no faces found in image")
}
faceRecord := result.FaceRecords[0]
if faceRecord.FaceDetail == nil || faceRecord.FaceDetail.BoundingBox == nil {
return nil, fmt.Errorf("no face detail available")
}
return &FaceDetection{
Confidence: float64(*faceRecord.Face.Confidence),
BoundingBox: BoundingBox{
Left: float64(*faceRecord.FaceDetail.BoundingBox.Left),
Top: float64(*faceRecord.FaceDetail.BoundingBox.Top),
Width: float64(*faceRecord.FaceDetail.BoundingBox.Width),
Height: float64(*faceRecord.FaceDetail.BoundingBox.Height),
},
}, nil
}
// SearchFacesByImage searches for similar faces
func (r *RekognitionClient) SearchFacesByImage(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error) {
input := &rekognition.SearchFacesByImageInput{
CollectionId: &r.collectionID,
Image: &types.Image{
Bytes: imageBytes,
},
MaxFaces: aws.Int32(int32(maxFaces)),
FaceMatchThreshold: aws.Float32(float32(threshold)),
}
result, err := r.client.SearchFacesByImage(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to search faces: %w", err)
}
var matches []FaceMatch
for _, match := range result.FaceMatches {
if match.Face == nil || match.Face.BoundingBox == nil {
continue
}
faceMatch := FaceMatch{
FaceID: aws.ToString(match.Face.FaceId),
ExternalImageID: aws.ToString(match.Face.ExternalImageId),
Similarity: float64(aws.ToFloat32(match.Similarity)),
BoundingBox: BoundingBox{
Left: float64(*match.Face.BoundingBox.Left),
Top: float64(*match.Face.BoundingBox.Top),
Width: float64(*match.Face.BoundingBox.Width),
Height: float64(*match.Face.BoundingBox.Height),
},
}
matches = append(matches, faceMatch)
}
return matches, nil
}
// ExtractImageBytes extracts image bytes from a multipart file
func ExtractImageBytes(file multipart.File) ([]byte, error) {
// Read all bytes
data, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
// Validate it's an image
_, _, err = image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("invalid image format: %w", err)
}
return data, nil
}
// NewRekognitionClientFromEnv creates a client from environment variables
func NewRekognitionClientFromEnv(collectionID string) (*RekognitionClient, error) {
cfg, err := config.LoadDefaultConfig(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to load AWS config: %w", err)
}
return NewRekognitionClient(cfg, collectionID), nil
}

220
internal/face/service.go Normal file
View File

@ -0,0 +1,220 @@
package face
import (
"context"
"crypto/sha256"
"fmt"
"log"
"time"
)
// PhotoRepository defines the photo operations needed by the face service
type PhotoRepository interface {
GetPhoto(ctx context.Context, id string) (*PhotoInfo, error)
CreateFaceEmbedding(ctx context.Context, embedding *FaceEmbeddingInfo) (*FaceEmbeddingInfo, error)
}
// PhotoInfo represents photo metadata needed by the face service
type PhotoInfo struct {
ID string `json:"id"`
SpaceID string `json:"spaceId"`
URL string `json:"url"`
ThumbnailURL string `json:"thumbnailUrl"`
FaceCount int `json:"faceCount"`
}
// FaceEmbeddingInfo represents a face embedding record
type FaceEmbeddingInfo struct {
ID string `json:"id"`
PhotoID string `json:"photoId"`
SpaceID string `json:"spaceId"`
Embedding []float32 `json:"embedding"`
BoundingBox string `json:"boundingBox"`
CreatedAt time.Time `json:"createdAt"`
}
// Service provides face detection, indexing, and search operations
type Service struct {
client Client
repo PhotoRepository
cache CacheService
config *Config
keys CacheKeys
}
// NewService creates a new face service
func NewService(client Client, repo PhotoRepository, cache CacheService, config *Config) *Service {
return &Service{
client: client,
repo: repo,
cache: cache,
config: config,
}
}
// ValidateImage validates that image bytes are a valid image
func (s *Service) ValidateImage(imageBytes []byte) error {
if len(imageBytes) > s.config.MaxImageSize {
return ErrImageTooLarge
}
if len(imageBytes) < 100 { // Minimum viable image size
return ErrImageTooSmall
}
// Check for valid image headers
if len(imageBytes) < 4 {
return ErrInvalidImageFormat
}
// JPEG: starts with FF D8 FF
// PNG: starts with 89 50 4E 47
// WebP: starts with 52 49 46 46 (RIFF)
if imageBytes[0] == 0xFF && imageBytes[1] == 0xD8 && imageBytes[2] == 0xFF {
return nil // JPEG
}
if imageBytes[0] == 0x89 && imageBytes[1] == 0x50 && imageBytes[2] == 0x4E && imageBytes[3] == 0x47 {
return nil // PNG
}
if imageBytes[0] == 0x52 && imageBytes[1] == 0x49 && imageBytes[2] == 0x46 && imageBytes[3] == 0x46 {
return nil // WebP (RIFF)
}
return ErrInvalidImageFormat
}
// IndexPhoto indexes all faces in a photo
func (s *Service) IndexPhoto(ctx context.Context, photoID string, imageBytes []byte) error {
// Check if already indexed
indexKey := s.keys.Indexed(photoID)
if indexed, _ := s.cache.Get(ctx, indexKey); indexed == "true" {
return nil
}
// Detect faces
detections, err := s.client.DetectFaces(ctx, imageBytes)
if err != nil {
return fmt.Errorf("failed to detect faces: %w", err)
}
if len(detections) == 0 {
log.Printf("No faces detected in photo %s", photoID)
return nil
}
// Get photo metadata
photo, err := s.repo.GetPhoto(ctx, photoID)
if err != nil {
return fmt.Errorf("failed to get photo: %w", err)
}
// Index each face with confidence > 99%
indexedCount := 0
for _, detection := range detections {
if detection.Confidence < 99.0 {
continue
}
// Index face in Rekognition
_, err := s.client.IndexFace(ctx, imageBytes, photoID)
if err != nil {
log.Printf("Failed to index face in photo %s: %v", photoID, err)
continue
}
// Store in database
embedding := &FaceEmbeddingInfo{
PhotoID: photoID,
SpaceID: photo.SpaceID,
Embedding: []float32{}, // Rekognition handles embeddings internally
BoundingBox: fmt.Sprintf("%.2f,%.2f,%.2f,%.2f",
detection.BoundingBox.Left,
detection.BoundingBox.Top,
detection.BoundingBox.Width,
detection.BoundingBox.Height),
}
if _, err := s.repo.CreateFaceEmbedding(ctx, embedding); err != nil {
log.Printf("Failed to store face embedding for photo %s: %v", photoID, err)
continue
}
indexedCount++
}
// Mark as indexed in cache
s.cache.Set(ctx, indexKey, "true", s.config.PhotoCacheTTL)
log.Printf("Indexed %d faces in photo %s", indexedCount, photoID)
return nil
}
// SearchFaces searches for faces in a space
func (s *Service) SearchFaces(ctx context.Context, spaceID string, imageBytes []byte, limit int) (*FaceSearchResult, error) {
startTime := time.Now()
// Check cache for similar searches
imageHash := computeHash(imageBytes)
cacheKey := s.keys.Search(spaceID, imageHash)
var cachedResult FaceSearchResult
if err := s.cache.GetJSON(ctx, cacheKey, &cachedResult); err == nil && cachedResult.Matches != nil {
cachedResult.CacheHit = true
cachedResult.SearchTime = time.Since(startTime)
return &cachedResult, nil
}
// Search in Rekognition
matches, err := s.client.SearchFacesByImage(ctx, imageBytes, limit, s.config.Threshold)
if err != nil {
return nil, fmt.Errorf("failed to search faces: %w", err)
}
// Get photo metadata for matches
var resultMatches []FaceMatch
for _, match := range matches {
// Get photo ID from external image ID
photoID := match.ExternalImageID
photo, err := s.repo.GetPhoto(ctx, photoID)
if err != nil {
log.Printf("Failed to get photo %s: %v", photoID, err)
continue
}
// Enrich match with photo data
match.PhotoID = photoID
match.PhotoURL = photo.URL
match.ThumbnailURL = photo.ThumbnailURL
resultMatches = append(resultMatches, match)
}
// Build result
result := &FaceSearchResult{
Matches: resultMatches,
PhotoCount: len(resultMatches),
SearchTime: time.Since(startTime),
CacheHit: false,
}
// Cache result
s.cache.SetJSON(ctx, cacheKey, result, s.config.SearchCacheTTL)
return result, nil
}
// CreateCollection creates a face collection for a space
func (s *Service) CreateCollection(ctx context.Context, collectionName string) error {
return s.client.CreateCollection(ctx, collectionName)
}
// DeleteCollection deletes a face collection for a space
func (s *Service) DeleteCollection(ctx context.Context, collectionName string) error {
return s.client.DeleteCollection(ctx, collectionName)
}
// computeHash computes a SHA256 hash of the image bytes for caching
func computeHash(data []byte) string {
// Use first 1KB for quick hashing (full hash is expensive for large images)
sampleSize := 1024
if len(data) < sampleSize {
sampleSize = len(data)
}
hash := sha256.Sum256(data[:sampleSize])
return fmt.Sprintf("%x", hash[:8]) // Use first 8 bytes for shorter key
}

View File

@ -0,0 +1,315 @@
package face
import (
"context"
"testing"
"time"
)
// MockPhotoRepository is a mock implementation of PhotoRepository for testing
type MockPhotoRepository struct {
photos map[string]*PhotoInfo
embeddings []*FaceEmbeddingInfo
}
func NewMockPhotoRepository() *MockPhotoRepository {
return &MockPhotoRepository{
photos: make(map[string]*PhotoInfo),
}
}
func (m *MockPhotoRepository) GetPhoto(ctx context.Context, id string) (*PhotoInfo, error) {
if photo, ok := m.photos[id]; ok {
return photo, nil
}
return &PhotoInfo{
ID: id,
SpaceID: "test-space",
URL: "https://example.com/photo.jpg",
}, nil
}
func (m *MockPhotoRepository) CreateFaceEmbedding(ctx context.Context, embedding *FaceEmbeddingInfo) (*FaceEmbeddingInfo, error) {
m.embeddings = append(m.embeddings, embedding)
return embedding, nil
}
func (m *MockPhotoRepository) AddPhoto(photo *PhotoInfo) {
m.photos[photo.ID] = photo
}
// Test helper functions
func setupTestService() (*Service, *MockClient, *MockPhotoRepository, *MemoryCache) {
mockClient := NewMockClient()
mockRepo := NewMockPhotoRepository()
cache := NewMemoryCache()
config := DefaultConfig()
service := NewService(mockClient, mockRepo, cache, config)
return service, mockClient, mockRepo, cache
}
// Tests
func TestService_IndexPhoto_Success(t *testing.T) {
service, mockClient, mockRepo, _ := setupTestService()
ctx := context.Background()
// Test data
photoID := "test-photo-1"
imageBytes := []byte("test-image-data")
// Add photo to mock repo
mockRepo.AddPhoto(&PhotoInfo{
ID: photoID,
SpaceID: "test-space-1",
})
// Execute
err := service.IndexPhoto(ctx, photoID, imageBytes)
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
_ = mockClient // Client was called internally
}
func TestService_IndexPhoto_AlreadyIndexed(t *testing.T) {
service, _, _, cache := setupTestService()
ctx := context.Background()
// Test data
photoID := "test-photo-1"
imageBytes := []byte("test-image-data")
// Pre-populate cache
cache.Set(ctx, "indexed:"+photoID, "true", 24*time.Hour)
// Execute
err := service.IndexPhoto(ctx, photoID, imageBytes)
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
}
func TestService_SearchFaces_Success(t *testing.T) {
service, _, mockRepo, _ := setupTestService()
ctx := context.Background()
// Test data
spaceID := "test-space-1"
imageBytes := []byte("test-image-data")
limit := 10
// Add photo to mock repo
mockRepo.AddPhoto(&PhotoInfo{
ID: "photo-1",
SpaceID: spaceID,
URL: "https://example.com/photo-1.jpg",
})
// Execute
result, err := service.SearchFaces(ctx, spaceID, imageBytes, limit)
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
if result == nil {
t.Fatal("Expected result, got nil")
}
if len(result.Matches) == 0 {
t.Error("Expected at least one match")
}
if result.CacheHit {
t.Error("Expected cache hit to be false")
}
}
func TestService_SearchFaces_CacheHit(t *testing.T) {
service, _, _, cache := setupTestService()
ctx := context.Background()
// Test data
spaceID := "test-space-1"
imageBytes := []byte("test-image-data")
limit := 10
// Pre-populate cache with a result
cachedResult := &FaceSearchResult{
Matches: []FaceMatch{
{
FaceID: "cached-face-1",
Similarity: 95.0,
},
},
PhotoCount: 1,
}
cache.SetJSON(ctx, "search:"+spaceID+":"+computeHash(imageBytes), cachedResult, 5*time.Minute)
// Execute
result, err := service.SearchFaces(ctx, spaceID, imageBytes, limit)
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
if result == nil {
t.Fatal("Expected result, got nil")
}
if !result.CacheHit {
t.Error("Expected cache hit to be true")
}
if result.PhotoCount != 1 {
t.Errorf("Expected 1 photo, got %d", result.PhotoCount)
}
}
func TestService_CreateCollection(t *testing.T) {
service, _, _, _ := setupTestService()
ctx := context.Background()
// Execute
err := service.CreateCollection(ctx, "test-collection")
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
}
func TestService_DeleteCollection(t *testing.T) {
service, _, _, _ := setupTestService()
ctx := context.Background()
// Execute
err := service.DeleteCollection(ctx, "test-collection")
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
}
func TestConfig_Default(t *testing.T) {
config := DefaultConfig()
if config.Region != "us-east-1" {
t.Errorf("Expected region us-east-1, got %s", config.Region)
}
if config.CollectionID != "hatch-faces" {
t.Errorf("Expected collection ID hatch-faces, got %s", config.CollectionID)
}
if config.MaxFaces != 10 {
t.Errorf("Expected max faces 10, got %d", config.MaxFaces)
}
if config.Threshold != 80.0 {
t.Errorf("Expected threshold 80.0, got %f", config.Threshold)
}
if config.SearchCacheTTL != 5*time.Minute {
t.Errorf("Expected search cache TTL 5m, got %v", config.SearchCacheTTL)
}
}
func TestConfig_Validate(t *testing.T) {
// Valid config
config := DefaultConfig()
if err := config.Validate(); err != nil {
t.Errorf("Expected valid config, got error: %v", err)
}
// Invalid: missing region
invalidConfig := DefaultConfig()
invalidConfig.Region = ""
if err := invalidConfig.Validate(); err == nil {
t.Error("Expected error for missing region")
}
// Invalid: missing collection ID
invalidConfig2 := DefaultConfig()
invalidConfig2.CollectionID = ""
if err := invalidConfig2.Validate(); err == nil {
t.Error("Expected error for missing collection ID")
}
}
func TestCacheKeys(t *testing.T) {
keys := CacheKeys{}
if keys.Search("space1", "hash1") != "search:space1:hash1" {
t.Error("Search key mismatch")
}
if keys.Photo("photo1") != "photo:photo1" {
t.Error("Photo key mismatch")
}
if keys.Face("face1") != "face:face1" {
t.Error("Face key mismatch")
}
if keys.Indexed("photo1") != "indexed:photo1" {
t.Error("Indexed key mismatch")
}
}
func TestValidateImage(t *testing.T) {
service, _, _, _ := setupTestService()
// Test with valid JPEG header (need at least 100 bytes)
validJPEG := make([]byte, 200)
validJPEG[0] = 0xFF
validJPEG[1] = 0xD8
validJPEG[2] = 0xFF
validJPEG[3] = 0xE0
if err := service.ValidateImage(validJPEG); err != nil {
t.Errorf("Expected no error for valid JPEG, got: %v", err)
}
// Test with valid PNG header (need at least 100 bytes)
validPNG := make([]byte, 200)
validPNG[0] = 0x89
validPNG[1] = 0x50
validPNG[2] = 0x4E
validPNG[3] = 0x47
if err := service.ValidateImage(validPNG); err != nil {
t.Errorf("Expected no error for valid PNG, got: %v", err)
}
// Test with too large image
largeImage := make([]byte, 11*1024*1024) // 11MB
if err := service.ValidateImage(largeImage); err != ErrImageTooLarge {
t.Errorf("Expected ErrImageTooLarge, got: %v", err)
}
// Test with too small image
smallImage := make([]byte, 10)
if err := service.ValidateImage(smallImage); err != ErrImageTooSmall {
t.Errorf("Expected ErrImageTooSmall, got: %v", err)
}
// Test with invalid format (large enough but wrong header)
invalidImage := make([]byte, 200)
if err := service.ValidateImage(invalidImage); err != ErrInvalidImageFormat {
t.Errorf("Expected ErrInvalidImageFormat, got: %v", err)
}
}
func TestComputeHash(t *testing.T) {
data1 := []byte("test data for hashing")
data2 := []byte("different data for hashing")
hash1 := computeHash(data1)
hash2 := computeHash(data2)
if hash1 == hash2 {
t.Error("Expected different hashes for different data")
}
// Same data should produce same hash
hash1Again := computeHash(data1)
if hash1 != hash1Again {
t.Error("Expected same hash for same data")
}
}