diff --git a/api/core/joke/getter.go b/api/core/joke/getter.go index 3d0b3f2..e947074 100644 --- a/api/core/joke/getter.go +++ b/api/core/joke/getter.go @@ -39,11 +39,26 @@ func GetJokeById(ctx context.Context, bucket *minio.Client, cache *redis.Client, } if err == nil { - return jokeFromMemory, "", nil + contentTypeFromMemory, err := memory.Get("id:" + strconv.Itoa(id) + ":content-type") + if err != nil && !errors.Is(err, bigcache.ErrEntryNotFound) { + return []byte{}, "", fmt.Errorf("acquiring joke content type from memory: %w", err) + } + + return jokeFromMemory, string(contentTypeFromMemory), nil } jokeFromCache, err := cache.Get(ctx, "jokes:id:"+strconv.Itoa(id)).Result() - if err != nil { + if err != nil && !errors.Is(err, redis.Nil) { + return []byte{}, "", fmt.Errorf("acquiring joke from cache: %w", err) + } + + if err == nil { + // Get content type + contentTypeFromCache, err := cache.Get(ctx, "jokes:id:"+strconv.Itoa(id)+":content-type").Result() + if err != nil && !errors.Is(err, redis.Nil) { + return []byte{}, "", fmt.Errorf("acquiring content type from cache: %w", err) + } + // Decode hex string to bytes imageBytes, err := hex.DecodeString(jokeFromCache) if err != nil { @@ -55,9 +70,14 @@ func GetJokeById(ctx context.Context, bucket *minio.Client, cache *redis.Client, if err != nil { log.Printf("setting memory cache: %s", err.Error()) } + + err = memory.Set("id:"+strconv.Itoa(id)+":content-type", []byte(contentTypeFromCache)) + if err != nil { + log.Printf("setting memory cache: %s", err.Error()) + } }(id, imageBytes) - return imageBytes, "", nil + return imageBytes, contentTypeFromCache, nil } jokes, err := ListJokesFromBucket(ctx, bucket, cache) @@ -91,6 +111,11 @@ func GetJokeById(ctx context.Context, bucket *minio.Client, cache *redis.Client, if err != nil { log.Printf("setting cache: %s", err.Error()) } + + err = cache.Set(ctx, "jokes:id:"+strconv.Itoa(id)+":content-type", jokes[id].ContentType, time.Hour*1).Err() + if err != nil { + log.Printf("setting cache: %s", err.Error()) + } }(id, image) return image, jokes[id].ContentType, nil diff --git a/api/core/joke/getter_test.go b/api/core/joke/getter_test.go new file mode 100644 index 0000000..aa81d08 --- /dev/null +++ b/api/core/joke/getter_test.go @@ -0,0 +1,71 @@ +package joke_test + +import ( + "context" + "jokes-bapak2-api/core/joke" + "testing" + "time" +) + +func TestGetRandomJoke(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + image, contentType, err := joke.GetRandomJoke(ctx, bucket, cache, memory) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if contentType != "image/jpeg" { + t.Errorf("expecting contentType of 'image/jpeg', instead got %s", contentType) + } + + if len(image) == 0 { + t.Error("empty image") + } +} + +func TestGetJokeById(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + image, contentType, err := joke.GetJokeById(ctx, bucket, cache, memory, 0) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if contentType != "image/jpeg" { + t.Errorf("expecting contentType of 'image/jpeg', instead got %s", contentType) + } + + if len(image) == 0 { + t.Error("empty image") + } + + cachedImage, cachedContentType, err := joke.GetJokeById(ctx, bucket, cache, memory, 0) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if cachedContentType != contentType { + t.Errorf("difference in contentType: original %s vs cached %s", contentType, cachedContentType) + } + + if string(cachedImage) != string(image) { + t.Errorf("difference in image bytes") + } + + cachedImage2, cachedContentType2, err := joke.GetJokeById(ctx, bucket, cache, memory, 0) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if cachedContentType2 != contentType { + t.Errorf("difference in contentType: original %s vs cached %s", contentType, cachedContentType2) + } + + if string(cachedImage2) != string(image) { + t.Errorf("difference in image bytes") + } + +} diff --git a/api/core/joke/jokes_test.go b/api/core/joke/joke_test.go similarity index 52% rename from api/core/joke/jokes_test.go rename to api/core/joke/joke_test.go index 56fd0e7..5a23744 100644 --- a/api/core/joke/jokes_test.go +++ b/api/core/joke/joke_test.go @@ -2,6 +2,7 @@ package joke_test import ( "context" + "fmt" "log" "os" "testing" @@ -61,12 +62,22 @@ func TestMain(m *testing.M) { memoryInstance, err := bigcache.NewBigCache(bigcache.DefaultConfig(time.Second * 30)) if err != nil { log.Fatalf("creating bigcache client: %s", err.Error()) + return } bucket = minioClient cache = redisClient memory = memoryInstance + setupCtx, setupCancel := context.WithTimeout(context.Background(), time.Minute) + defer setupCancel() + + err = setupBucketStorage(setupCtx, minioClient) + if err != nil { + log.Fatalf("set up bucket storage: %v", err) + return + } + exitCode := m.Run() cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), time.Minute) @@ -77,7 +88,12 @@ func TestMain(m *testing.M) { log.Printf("flushing redis: %s", err.Error()) } - err = cache.Close() + err = minioClient.RemoveBucketWithOptions(cleanupCtx, "jokesbapak2", minio.RemoveBucketOptions{ForceDelete: true}) + if err != nil { + log.Printf("removing bucket: %s", err.Error()) + } + + err = memoryInstance.Close() if err != nil { log.Printf("closing cache client: %s", err.Error()) } @@ -89,3 +105,52 @@ func TestMain(m *testing.M) { os.Exit(exitCode) } + +func setupBucketStorage(ctx context.Context, minioClient *minio.Client) error { + bucketFound, err := minioClient.BucketExists(ctx, "jokesbapak2") + if err != nil { + return fmt.Errorf("checking MinIO bucket: %w", err) + } + + if !bucketFound { + err = minioClient.MakeBucket(ctx, "jokesbapak2", minio.MakeBucketOptions{}) + if err != nil { + return fmt.Errorf("creating MinIO bucket: %w", err) + } + + policy := `{ + "Version":"2012-10-17", + "Statement":[ + { + "Sid": "AddPerm", + "Effect": "Allow", + "Principal": "*", + "Action":["s3:GetObject"], + "Resource":["arn:aws:s3:::jokesbapak2/*"] + } + ] + }` + + err = minioClient.SetBucketPolicy(ctx, "jokesbapak2", policy) + if err != nil { + return fmt.Errorf("setting bucket policy: %w", err) + } + } + + sampleFiles := []string{ + "../../samples/sample1.jpg", + "../../samples/sample2.jpg", + "../../samples/sample3.jpg", + "../../samples/sample4.jpg", + "../../samples/sample5.jpg", + } + + for i, file := range sampleFiles { + _, err := minioClient.FPutObject(ctx, "jokesbapak2", fmt.Sprintf("sample%d.jpg", i), file, minio.PutObjectOptions{ContentType: "image/jpeg"}) + if err != nil { + return fmt.Errorf("putting object: %w", err) + } + } + + return nil +} diff --git a/api/core/joke/list.go b/api/core/joke/list.go index 67b5578..db50f25 100644 --- a/api/core/joke/list.go +++ b/api/core/joke/list.go @@ -37,8 +37,18 @@ func ListJokesFromBucket(ctx context.Context, bucket *minio.Client, cache *redis return []Joke{}, fmt.Errorf("enumerating objects: %w", object.Err) } + var contentType = object.ContentType + + if contentType == "" { + stat, err := bucket.StatObject(ctx, JokesBapak2Bucket, object.Key, minio.StatObjectOptions{}) + if err != nil { + return []Joke{}, fmt.Errorf("stat object: %w", err) + } + + contentType = stat.ContentType + } if !object.IsDeleteMarker { - jokes = append(jokes, Joke{ModifiedAt: object.Restore.ExpiryTime, FileName: object.Key, ContentType: object.ContentType}) + jokes = append(jokes, Joke{ModifiedAt: object.LastModified, FileName: object.Key, ContentType: contentType}) } } diff --git a/api/core/joke/list_test.go b/api/core/joke/list_test.go new file mode 100644 index 0000000..ae8a083 --- /dev/null +++ b/api/core/joke/list_test.go @@ -0,0 +1,22 @@ +package joke_test + +import ( + "context" + "jokes-bapak2-api/core/joke" + "testing" + "time" +) + +func TestListJokeFromBucket(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + jokes, err := joke.ListJokesFromBucket(ctx, bucket, cache) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if len(jokes) != 5 { + t.Errorf("expected joke to have a length of 5, instead got %d", len(jokes)) + } +} diff --git a/api/core/joke/today.go b/api/core/joke/today.go index 4a54368..9d3eb82 100644 --- a/api/core/joke/today.go +++ b/api/core/joke/today.go @@ -37,6 +37,12 @@ func GetTodaysJoke(ctx context.Context, bucket *minio.Client, cache *redis.Clien } if err == nil { + // Get content type + contentTypeFromCache, err := cache.Get(ctx, "jokes:today:"+today+":content-type").Result() + if err != nil && !errors.Is(err, redis.Nil) { + return []byte{}, "", fmt.Errorf("acquiring content type from cache: %w", err) + } + // Decode hex string to bytes imageBytes, err := hex.DecodeString(jokeFromCache) if err != nil { @@ -48,9 +54,14 @@ func GetTodaysJoke(ctx context.Context, bucket *minio.Client, cache *redis.Clien if err != nil { log.Printf("setting memory cache: %s", err.Error()) } + + err = memory.Set("today:"+today+":content-type", []byte(contentTypeFromCache)) + if err != nil { + log.Printf("setting memory cache: %s", err.Error()) + } }(today, imageBytes) - return imageBytes, "", nil + return imageBytes, contentTypeFromCache, nil } // If everything not exists, we get a new random joke @@ -70,6 +81,11 @@ func GetTodaysJoke(ctx context.Context, bucket *minio.Client, cache *redis.Clien if err != nil { log.Printf("setting today cache to redis: %s", err.Error()) } + + err = cache.Set(ctx, "jokes:today:"+today+":content-type", contentType, time.Hour*24).Err() + if err != nil { + log.Printf("setting today cache to redis: %s", err.Error()) + } }(today, randomJoke) return randomJoke, contentType, nil diff --git a/api/core/joke/today_test.go b/api/core/joke/today_test.go new file mode 100644 index 0000000..20ecafc --- /dev/null +++ b/api/core/joke/today_test.go @@ -0,0 +1,39 @@ +package joke_test + +import ( + "context" + "jokes-bapak2-api/core/joke" + "testing" + "time" +) + +func TestGetTodaysJoke(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + image, contentType, err := joke.GetTodaysJoke(ctx, bucket, cache, memory) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if contentType != "image/jpeg" { + t.Errorf("expecting contentType of 'image/jpeg', instead got %s", contentType) + } + + if len(image) == 0 { + t.Errorf("empty image") + } + + cachedImage, cachedContentType, err := joke.GetTodaysJoke(ctx, bucket, cache, memory) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if contentType != cachedContentType { + t.Errorf("difference on contentType: original %s vs cached %s", contentType, cachedContentType) + } + + if string(image) != string(cachedImage) { + t.Errorf("difference in image") + } +} diff --git a/api/core/joke/total_test.go b/api/core/joke/total_test.go new file mode 100644 index 0000000..cd62d30 --- /dev/null +++ b/api/core/joke/total_test.go @@ -0,0 +1,22 @@ +package joke_test + +import ( + "context" + "jokes-bapak2-api/core/joke" + "testing" + "time" +) + +func TestGetTotalJoke(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + total, err := joke.GetTotalJoke(ctx, bucket, cache, memory) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if total != 5 { + t.Errorf("expecting total to be 5 instead got %d", total) + } +}