mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-03 20:13:03 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const requestIDKey = "request_id"
|
||||
|
||||
func requestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.GetHeader("X-Request-ID")
|
||||
if id == "" {
|
||||
id = newRequestID()
|
||||
}
|
||||
c.Set(requestIDKey, id)
|
||||
c.Header("X-Request-ID", id)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func accessLog(logger *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
logger.Info("http request",
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", c.FullPath()),
|
||||
zap.Int("status", c.Writer.Status()),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.String("request_id", requestIDFromContext(c)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func recovery(logger *zap.Logger) gin.HandlerFunc {
|
||||
return gin.CustomRecovery(func(c *gin.Context, recovered any) {
|
||||
logger.Error("panic recovered", zap.Any("panic", recovered), zap.String("request_id", requestIDFromContext(c)))
|
||||
problem(c, http.StatusInternalServerError, "https://spotify-rec.local/errors/internal", "Internal server error", "The server encountered an unexpected error.")
|
||||
})
|
||||
}
|
||||
|
||||
func apiKeyAuth(keys []string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if len(keys) == 0 || c.Request.URL.Path == "/healthz" || c.Request.URL.Path == "/readyz" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
key := c.GetHeader("X-API-Key")
|
||||
if !slices.Contains(keys, key) {
|
||||
problem(c, http.StatusUnauthorized, "https://spotify-rec.local/errors/unauthorized", "Unauthorized", "A valid X-API-Key header is required.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func newRequestID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return time.Now().UTC().Format("20060102150405.000000000")
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func requestIDFromContext(c *gin.Context) string {
|
||||
value, ok := c.Get(requestIDKey)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
id, _ := value.(string)
|
||||
return id
|
||||
}
|
||||
|
||||
func cors() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin == "" {
|
||||
origin = "*"
|
||||
}
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, X-API-Key, X-Request-ID")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Problem struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
}
|
||||
|
||||
func problem(c *gin.Context, status int, problemType, title, detail string) {
|
||||
if c.Writer.Written() {
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "application/problem+json")
|
||||
c.JSON(status, Problem{
|
||||
Type: problemType,
|
||||
Title: title,
|
||||
Status: status,
|
||||
Detail: detail,
|
||||
Instance: c.Request.URL.Path,
|
||||
})
|
||||
}
|
||||
|
||||
func notFound(c *gin.Context) {
|
||||
problem(c, http.StatusNotFound, "https://spotify-rec.local/errors/not-found", "Not found", "The requested resource does not exist.")
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/musicbrainz"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/songlink"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/spotify"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/webplayer"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/storage/memory"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestSpotifyImportEndpoint(t *testing.T) {
|
||||
store := memory.New()
|
||||
spotifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/v1/tracks/imported":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "imported",
|
||||
"name": "Imported",
|
||||
"artists": []map[string]string{{"name": "Artist"}},
|
||||
"album": map[string]any{"name": "Album", "release_date": "2025-01-01"},
|
||||
"duration_ms": 180000,
|
||||
"popularity": 60,
|
||||
"external_ids": map[string]string{"isrc": "USRC17607839"},
|
||||
})
|
||||
case "/v1/audio-features/imported":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"danceability": 0.6, "energy": 0.7, "loudness": -6, "speechiness": 0.05,
|
||||
"acousticness": 0.2, "instrumentalness": 0, "liveness": 0.1, "valence": 0.5,
|
||||
"tempo": 110, "time_signature": 4, "key": 5, "mode": 1,
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer spotifyServer.Close()
|
||||
|
||||
service := provider.NewService(store,
|
||||
spotify.New(spotify.Config{BearerToken: "secret-token", APIBaseURL: spotifyServer.URL + "/v1"}),
|
||||
webplayer.NewClient(),
|
||||
songlink.NewClient(),
|
||||
musicbrainz.New(musicbrainz.Config{AppName: "SpotifyRecAlg", Contact: "test@example.com", BaseURL: "http://127.0.0.1:1", MinDelay: time.Nanosecond}),
|
||||
provider.ServiceConfig{DefaultMarket: "US", CacheTTL: time.Hour},
|
||||
)
|
||||
|
||||
router := NewRouter(RouterConfig{
|
||||
Store: store,
|
||||
Engine: recommendation.NewEngine(recommendation.EngineConfig{}),
|
||||
Provider: service,
|
||||
Logger: zap.NewNop(),
|
||||
})
|
||||
|
||||
body := bytes.NewBufferString(`{"source":{"type":"url","value":"https://open.spotify.com/track/imported"},"market":"US","persist":true}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/providers/spotify/import", body)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp provider.ImportResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.ImportedTracks != 1 {
|
||||
t.Fatalf("imported tracks = %d, want 1", resp.ImportedTracks)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/v1/providers/status", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status endpoint = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if bytes.Contains(rec.Body.Bytes(), []byte("secret-token")) {
|
||||
t.Fatal("status response leaked bearer token")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
recommendation.SnapshotProvider
|
||||
Ping(ctx context.Context) error
|
||||
UpsertTrack(ctx context.Context, track recommendation.Track) error
|
||||
UpsertTracks(ctx context.Context, tracks []recommendation.Track) error
|
||||
RecordInteraction(ctx context.Context, interaction recommendation.Interaction) error
|
||||
GetControls(ctx context.Context, userID string) (recommendation.UserControls, error)
|
||||
UpsertControls(ctx context.Context, controls recommendation.UserControls) error
|
||||
}
|
||||
|
||||
type RouterConfig struct {
|
||||
Store Store
|
||||
Engine *recommendation.Engine
|
||||
Provider *provider.Service
|
||||
Logger *zap.Logger
|
||||
APIKeys []string
|
||||
Version string
|
||||
}
|
||||
|
||||
func NewRouter(cfg RouterConfig) http.Handler {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
router := gin.New()
|
||||
router.Use(recovery(cfg.Logger), cors(), requestID(), accessLog(cfg.Logger), apiKeyAuth(cfg.APIKeys))
|
||||
|
||||
handler := handler{
|
||||
store: cfg.Store,
|
||||
engine: cfg.Engine,
|
||||
provider: cfg.Provider,
|
||||
logger: cfg.Logger,
|
||||
version: cfg.Version,
|
||||
}
|
||||
|
||||
router.GET("/healthz", handler.health)
|
||||
router.GET("/readyz", handler.ready)
|
||||
|
||||
v1 := router.Group("/v1")
|
||||
v1.GET("/openapi.yaml", handler.openapi)
|
||||
v1.POST("/tracks", handler.upsertTrack)
|
||||
v1.PUT("/tracks/batch", handler.upsertTracks)
|
||||
v1.POST("/interactions", handler.recordInteraction)
|
||||
v1.POST("/recommendations", handler.recommend)
|
||||
v1.GET("/users/:user_id/taste-profile", handler.tasteProfile)
|
||||
v1.GET("/users/:user_id/controls", handler.getControls)
|
||||
v1.PUT("/users/:user_id/controls", handler.upsertControls)
|
||||
v1.POST("/providers/spotify/import", handler.importSpotify)
|
||||
v1.POST("/providers/spotify/search", handler.searchSpotify)
|
||||
v1.POST("/providers/musicbrainz/enrich", handler.enrichMusicBrainz)
|
||||
v1.GET("/providers/status", handler.providerStatus)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
store Store
|
||||
engine *recommendation.Engine
|
||||
provider *provider.Service
|
||||
logger *zap.Logger
|
||||
version string
|
||||
}
|
||||
|
||||
func (h handler) health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "version": h.version})
|
||||
}
|
||||
|
||||
func (h handler) ready(c *gin.Context) {
|
||||
if err := h.store.Ping(c.Request.Context()); err != nil {
|
||||
problem(c, http.StatusServiceUnavailable, "https://spotify-rec.local/errors/storage-unavailable", "Storage unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ready"})
|
||||
}
|
||||
|
||||
func (h handler) openapi(c *gin.Context) {
|
||||
c.File("docs/openapi.yaml")
|
||||
}
|
||||
|
||||
func (h handler) upsertTrack(c *gin.Context) {
|
||||
var req recommendation.Track
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
problem(c, http.StatusBadRequest, "https://spotify-rec.local/errors/invalid-json", "Invalid JSON", err.Error())
|
||||
return
|
||||
}
|
||||
if err := recommendation.ValidateTrack(req); err != nil {
|
||||
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/validation", "Validation failed", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.store.UpsertTrack(c.Request.Context(), req); err != nil {
|
||||
h.logger.Error("upsert track", zap.Error(err))
|
||||
problem(c, http.StatusInternalServerError, "https://spotify-rec.local/errors/storage-write", "Storage write failed", "Track could not be stored.")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, req)
|
||||
}
|
||||
|
||||
func (h handler) upsertTracks(c *gin.Context) {
|
||||
var req struct {
|
||||
Tracks []recommendation.Track `json:"tracks" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
problem(c, http.StatusBadRequest, "https://spotify-rec.local/errors/invalid-json", "Invalid JSON", err.Error())
|
||||
return
|
||||
}
|
||||
if len(req.Tracks) == 0 {
|
||||
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/validation", "Validation failed", "tracks must contain at least one item")
|
||||
return
|
||||
}
|
||||
if len(req.Tracks) > 1000 {
|
||||
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/validation", "Validation failed", "batch limit is 1000 tracks")
|
||||
return
|
||||
}
|
||||
for i, track := range req.Tracks {
|
||||
if err := recommendation.ValidateTrack(track); err != nil {
|
||||
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/validation", "Validation failed", "tracks["+strconv.Itoa(i)+"]: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.store.UpsertTracks(c.Request.Context(), req.Tracks); err != nil {
|
||||
h.logger.Error("upsert tracks", zap.Error(err))
|
||||
problem(c, http.StatusInternalServerError, "https://spotify-rec.local/errors/storage-write", "Storage write failed", "Tracks could not be stored.")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"stored": len(req.Tracks)})
|
||||
}
|
||||
|
||||
func (h handler) recordInteraction(c *gin.Context) {
|
||||
var req recommendation.Interaction
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
problem(c, http.StatusBadRequest, "https://spotify-rec.local/errors/invalid-json", "Invalid JSON", err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.UserID) == "" || strings.TrimSpace(req.TrackID) == "" || strings.TrimSpace(string(req.Type)) == "" {
|
||||
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/validation", "Validation failed", "user_id, track_id, and type are required")
|
||||
return
|
||||
}
|
||||
if err := h.store.RecordInteraction(c.Request.Context(), req); err != nil {
|
||||
h.logger.Error("record interaction", zap.Error(err))
|
||||
problem(c, http.StatusInternalServerError, "https://spotify-rec.local/errors/storage-write", "Storage write failed", "Interaction could not be stored.")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"accepted": true})
|
||||
}
|
||||
|
||||
func (h handler) recommend(c *gin.Context) {
|
||||
var req recommendation.RecommendRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
problem(c, http.StatusBadRequest, "https://spotify-rec.local/errors/invalid-json", "Invalid JSON", err.Error())
|
||||
return
|
||||
}
|
||||
recs, profile, err := h.engine.Recommend(c.Request.Context(), h.store, req)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
return
|
||||
case strings.Contains(err.Error(), "required"), strings.Contains(err.Error(), "empty"):
|
||||
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/validation", "Validation failed", err.Error())
|
||||
default:
|
||||
h.logger.Error("recommend", zap.Error(err))
|
||||
problem(c, http.StatusInternalServerError, "https://spotify-rec.local/errors/recommendation-failed", "Recommendation failed", "The recommendation engine could not complete the request.")
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": recs,
|
||||
"taste_profile": profile,
|
||||
"pagination": gin.H{"next_cursor": nil, "has_more": false},
|
||||
})
|
||||
}
|
||||
|
||||
func (h handler) tasteProfile(c *gin.Context) {
|
||||
userID := c.Param("user_id")
|
||||
profile, err := h.engine.TasteProfile(c.Request.Context(), h.store, userID)
|
||||
if err != nil {
|
||||
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/profile-unavailable", "Taste profile unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, profile)
|
||||
}
|
||||
|
||||
func (h handler) getControls(c *gin.Context) {
|
||||
controls, err := h.store.GetControls(c.Request.Context(), c.Param("user_id"))
|
||||
if err != nil {
|
||||
h.logger.Error("get controls", zap.Error(err))
|
||||
problem(c, http.StatusInternalServerError, "https://spotify-rec.local/errors/storage-read", "Storage read failed", "Controls could not be loaded.")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, controls)
|
||||
}
|
||||
|
||||
func (h handler) upsertControls(c *gin.Context) {
|
||||
var req recommendation.UserControls
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
problem(c, http.StatusBadRequest, "https://spotify-rec.local/errors/invalid-json", "Invalid JSON", err.Error())
|
||||
return
|
||||
}
|
||||
req.UserID = c.Param("user_id")
|
||||
if strings.TrimSpace(req.UserID) == "" {
|
||||
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/validation", "Validation failed", "user_id is required")
|
||||
return
|
||||
}
|
||||
if err := h.store.UpsertControls(c.Request.Context(), req); err != nil {
|
||||
h.logger.Error("upsert controls", zap.Error(err))
|
||||
problem(c, http.StatusInternalServerError, "https://spotify-rec.local/errors/storage-write", "Storage write failed", "Controls could not be stored.")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, req)
|
||||
}
|
||||
|
||||
func (h handler) importSpotify(c *gin.Context) {
|
||||
service, ok := h.providerService(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req provider.ImportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
problem(c, http.StatusBadRequest, "https://spotify-rec.local/errors/invalid-json", "Invalid JSON", err.Error())
|
||||
return
|
||||
}
|
||||
resp, err := service.ImportSpotify(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
h.providerProblem(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h handler) searchSpotify(c *gin.Context) {
|
||||
service, ok := h.providerService(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req provider.SearchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
problem(c, http.StatusBadRequest, "https://spotify-rec.local/errors/invalid-json", "Invalid JSON", err.Error())
|
||||
return
|
||||
}
|
||||
resp, err := service.SearchSpotify(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
h.providerProblem(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h handler) enrichMusicBrainz(c *gin.Context) {
|
||||
service, ok := h.providerService(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req provider.EnrichRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
problem(c, http.StatusBadRequest, "https://spotify-rec.local/errors/invalid-json", "Invalid JSON", err.Error())
|
||||
return
|
||||
}
|
||||
resp, err := service.EnrichMusicBrainz(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
h.providerProblem(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h handler) providerStatus(c *gin.Context) {
|
||||
service, ok := h.providerService(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, service.Status(c.Request.Context()))
|
||||
}
|
||||
|
||||
func (h handler) providerService(c *gin.Context) (*provider.Service, bool) {
|
||||
if h.provider == nil {
|
||||
problem(c, http.StatusServiceUnavailable, "https://spotify-rec.local/errors/provider-unavailable", "Provider unavailable", "Provider imports are not configured for this storage backend.")
|
||||
return nil, false
|
||||
}
|
||||
return h.provider, true
|
||||
}
|
||||
|
||||
func (h handler) providerProblem(c *gin.Context, err error) {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
message := err.Error()
|
||||
switch {
|
||||
case strings.Contains(message, "not configured"), strings.Contains(message, "credentials"):
|
||||
problem(c, http.StatusServiceUnavailable, "https://spotify-rec.local/errors/provider-not-configured", "Provider not configured", message)
|
||||
case strings.Contains(message, "required"), strings.Contains(message, "unsupported"), strings.Contains(message, "must be"):
|
||||
problem(c, http.StatusUnprocessableEntity, "https://spotify-rec.local/errors/provider-validation", "Validation failed", message)
|
||||
default:
|
||||
h.logger.Error("provider request", zap.Error(err))
|
||||
problem(c, http.StatusBadGateway, "https://spotify-rec.local/errors/provider-request-failed", "Provider request failed", "The upstream provider request could not be completed.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
||||
memstore "github.com/tdvorak/spotifyrecalg/apps/backend/internal/storage/memory"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestRecommendationEndpoint(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
store := memstore.New()
|
||||
memstore.SeedDemo(store)
|
||||
engine := recommendation.NewEngine(recommendation.EngineConfig{
|
||||
Now: func() time.Time { return time.Date(2026, 4, 13, 12, 0, 0, 0, time.UTC) },
|
||||
ContentWeight: 0.44,
|
||||
CollabWeight: 0.28,
|
||||
PopularityWeight: 0.08,
|
||||
ExplorationWeight: 0.20,
|
||||
DiversityLambda: 0.74,
|
||||
})
|
||||
router := NewRouter(RouterConfig{
|
||||
Store: store,
|
||||
Engine: engine,
|
||||
Logger: zap.NewNop(),
|
||||
Version: "test",
|
||||
})
|
||||
|
||||
body := bytes.NewBufferString(`{"user_id":"demo-user","limit":3,"mode":"balanced"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/recommendations", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(`"taste_profile"`)) {
|
||||
t.Fatalf("expected taste profile in response: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyMiddleware(t *testing.T) {
|
||||
router := NewRouter(RouterConfig{
|
||||
Store: memstore.New(),
|
||||
Engine: recommendation.NewEngine(recommendation.EngineConfig{}),
|
||||
Logger: zap.NewNop(),
|
||||
APIKeys: []string{"secret"},
|
||||
Version: "test",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/recommendations", bytes.NewBufferString(`{}`))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user