From 0d334bde380c54b6a8707bdde19f53546738e2c3 Mon Sep 17 00:00:00 2001 From: froge Date: Wed, 12 Feb 2025 10:40:20 +1000 Subject: [PATCH] Fixed spotify artist route, added tests, rebuilt environment, rebuilt DB handling --- database.go | 45 ++++++++++++++++++++++++ databse.go | 39 --------------------- main.go | 33 +++++++++++------- routes.go | 95 ++++++++++++++++++-------------------------------- routes_test.go | 31 ++++++++++++++-- utils.go | 47 +++++++++++++++++++++++++ 6 files changed, 174 insertions(+), 116 deletions(-) create mode 100644 database.go delete mode 100644 databse.go create mode 100644 utils.go diff --git a/database.go b/database.go new file mode 100644 index 0000000..7184cee --- /dev/null +++ b/database.go @@ -0,0 +1,45 @@ +package main + +import ( + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "log/slog" +) + +type ArtistProfile struct { + gorm.Model + SpotifyID string `gorm:"unique"` + Name string + Popularity int + Genres []Genre `gorm:"many2many:artist_genres;"` +} + +type Genre struct { + gorm.Model + Name string `gorm:"unique"` +} + +func setupTestDatabase(name string) *gorm.DB { + slog.Info("[GOMUSIC] Setting up new test database in memory") + // Open a named DB instance so each test has a clean environment + db, err := gorm.Open(sqlite.Open("file:test1?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + panic("Failed to open database") + } + + // Run model migrations here to keep DB consistent + db.AutoMigrate(&ArtistProfile{}, &Genre{}) + return db +} + +func setupDatabase() *gorm.DB { + slog.Info("[GOMUSIC] Setting up database and running auto migrations") + db, err := gorm.Open(sqlite.Open("gomusic.db"), &gorm.Config{}) + if err != nil { + panic("Failed to open database") + } + + // Run model migrations here to keep DB consistent + db.AutoMigrate(&ArtistProfile{}, &Genre{}) + return db +} diff --git a/databse.go b/databse.go deleted file mode 100644 index 22ebb8a..0000000 --- a/databse.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import ( - "gorm.io/gorm" - "gorm.io/driver/sqlite" -) - -type ArtistInfo struct { - gorm.Model - Name string - Popularity string - SpotifyID string - ArtistGenre ArtistGenre -} - -// Stores an association between several Genres and an individual artist -type ArtistGenre struct { - gorm.Model - Genres []Genre - ArtistInfoID uint -} - -type Genre struct { - gorm.Model - Genre string - ArtistGenreID uint -} - -func setupDatabase() *gorm.DB { - db, err := gorm.Open(sqlite.Open("gomusic.db"), &gorm.Config{}) - if err != nil { - panic("Failed to open database") - } - - // Run model migrations here to keep DB consistent - db.AutoMigrate(&ArtistInfo{}, &ArtistGenre{}, &Genre{}) - - return db -} diff --git a/main.go b/main.go index e4735d9..e9ddd16 100644 --- a/main.go +++ b/main.go @@ -2,25 +2,29 @@ package main import ( "github.com/gin-gonic/gin" - "os" + "gorm.io/gorm" "log/slog" + "os" ) +// Environment type is used with route methods +// So that we can easily inject/contain context data like DB connections +type Env struct { + db *gorm.DB +} + // Grab some required spotify credentials from the environment var spotifyClientID = os.Getenv("SPOTIFY_ID") var spotifyClientSecret = os.Getenv("SPOTIFY_SECRET") -// Make sure the DB is in a good state to launch -var db = setupDatabase() - -func setupRouter() *gin.Engine { +func setupRouter(env *Env, spotifyID string, spotifySecret string) *gin.Engine { var r *gin.Engine = gin.Default() - - // Add middleware to handle spotify auth for us - r.Use(spotifyAuth(spotifyClientID, spotifyClientSecret)) - - r.GET("/ping", ping) - r.GET("/artists/:artistID", getArtistByID) + + // Add our spotify auth middleware + r.Use(spotifyAuth(spotifyID, spotifySecret)) + + r.GET("/ping", env.ping) + r.GET("/artists/:artistID", env.getArtistByID) return r } @@ -33,7 +37,10 @@ func main() { slog.Warn("[GOMUSIC] No Spotify secret configured in 'SPOTIFY_SECRET' environment variable") } - // Router and server setup - r := setupRouter() + var db = setupDatabase() + env := &Env{db: db} + + // Router/middleware and server setup + r := setupRouter(env, spotifyClientID, spotifyClientSecret) r.Run(":8080") } diff --git a/routes.go b/routes.go index 8747169..1d2e085 100644 --- a/routes.go +++ b/routes.go @@ -2,82 +2,55 @@ package main import ( "github.com/gin-gonic/gin" + "gorm.io/gorm/clause" "net/http" "log/slog" - "encoding/json" - "fmt" - "io" ) -type SpotifyResponse struct { - ID string - Name string - Popularity int - Genres []string -} - - -func ping(c *gin.Context) { +func (env *Env) ping(c *gin.Context) { c.String(http.StatusOK, "pong") } -func getArtistByID(c *gin.Context) { +func (env *Env) getArtistByID(c *gin.Context) { artistID := c.Params.ByName("artistID") spotifyAuthToken := c.GetString("spotifyAuthToken") - + if artistID == "" || spotifyAuthToken == "Bearer " { c.JSON(http.StatusBadRequest, gin.H{"Error": "Could not find required parameters and/or required authentication tokens"}) - return + return } - // Make a request to spotify API to grab artist data - artistEndpoint := fmt.Sprintf("https://api.spotify.com/v1/artists/%s", artistID) - req, err := http.NewRequest("GET", artistEndpoint, nil) - if err != nil { - slog.Error("[GOMUSIC] Failed to build HTTP request", "Error", err) - c.JSON(http.StatusInternalServerError, gin.H{"Error": "Failed to request latest spotify data"}) - return - } - req.Header.Add("Authorization", spotifyAuthToken) - - // Send off request - resp, err := http.DefaultClient.Do(req) - if err != nil { - slog.Error("[GOMUSIC] Failed to get artist data from spotify API", "Error", err) - c.JSON(http.StatusInternalServerError, gin.H{"Error": "Failed to request latest spotify data"}) - return - } else if resp.StatusCode != 200 { - slog.Error("[GOMUSIC] Failed to get artist data from spotify API", "Error", resp.Status) - c.JSON(http.StatusInternalServerError, gin.H{"Error": "Failed to request latest spotify data"}) - return - - } - respData, err := io.ReadAll(resp.Body) - if err != nil { - slog.Error("[GOMUSIC] Failed to read response data from spotify API", "Error", resp.Status) - c.JSON(http.StatusInternalServerError, gin.H{"Error": "Failed to request latest spotify data"}) - return - - } + spotifyResponse, err := getSpotifyArtistData(artistID, spotifyAuthToken) + if err != nil { + slog.Error("[GOMUSIC] Failed to request latest spotify data from API", "Error", err) + c.JSON(http.StatusInternalServerError, gin.H{"Error": "Failed to request the latest data from spotify API"}) + return + } - // Close this immediately since it's unused now - resp.Body.Close() + // Update DB here + var genreList []Genre + for _, val := range spotifyResponse.Genres { + genreList = append(genreList, Genre {Name: val}) + } - var spotifyResponse SpotifyResponse - err = json.Unmarshal(respData, &spotifyResponse) - if err != nil { - slog.Error("[GOMUSIC] Failed to read response body data from spotify", "Error", err) - c.JSON(http.StatusInternalServerError, gin.H{"Error": "Failed to request latest spotify data"}) - return - } - - // Update DB here - //value, ok := db[artistID] - //if ok { - // c.JSON(http.StatusOK, gin.H{"artistID": artistID, "value": value}) - //} else { - // c.JSON(http.StatusOK, gin.H{"artistID": artistID, "status": "no value"}) - //} + artistProfile := ArtistProfile{ + SpotifyID: spotifyResponse.ID, + Name: spotifyResponse.Name, + Popularity: spotifyResponse.Popularity, + Genres: genreList, + } + + // Create new record + // Otherwise update values when artist with this SpotifyID already exists + // Basically upsert + dbResult := env.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "spotify_id"}}, + UpdateAll: true, + }).Create(&artistProfile) + + if dbResult.Error != nil { + slog.Error("[GOMUSIC] Failed to store response in local database", "Error", err) + } // Send back our response data c.JSON(http.StatusOK, spotifyResponse) diff --git a/routes_test.go b/routes_test.go index ee4de8f..41fd2cc 100644 --- a/routes_test.go +++ b/routes_test.go @@ -2,13 +2,17 @@ package main import ( "net/http" + "encoding/json" "net/http/httptest" "github.com/stretchr/testify/assert" "testing" + "fmt" ) func TestPingRoute(t *testing.T) { - router := setupRouter() + db := setupTestDatabase("testping") + env := &Env{db: db} + router := setupRouter(env, spotifyClientID, spotifyClientSecret) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/ping", nil) @@ -19,12 +23,33 @@ func TestPingRoute(t *testing.T) { } func TestGetArtistByIDRoute(t *testing.T) { - router := setupRouter() + db := setupTestDatabase("testgetartistbyid") + env := &Env{db: db} + router := setupRouter(env, spotifyClientID, spotifyClientSecret) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/artists/0TnOYISbd1XYRBk9myaseg", nil) router.ServeHTTP(w, req) assert.Equal(t, 200, w.Code) - assert.Equal(t, `{"ID":"0TnOYISbd1XYRBk9myaseg","Name":"Pitbull","Popularity":83,"Genres":[]}`, w.Body.String()) + + // Dynamic data is hard to test so here we validate JSON response + // And we check known fields which are not likely to change + // We then check that the DB was updated correctly just to be sure + var spotifyResp SpotifyResponse + err := json.NewDecoder(w.Body).Decode(&spotifyResp) + if err != nil { + assert.Fail(t, fmt.Sprintf("Could not validate and parse JSON response into SpotifyResponse struct: %s", err.Error())) + } + assert.Equal(t, "0TnOYISbd1XYRBk9myaseg", spotifyResp.ID) + assert.Equal(t, "Pitbull", spotifyResp.Name) + + var artist ArtistProfile + dbResult := env.db.First(&artist) + if dbResult.Error != nil { + assert.Fail(t, fmt.Sprintf("Failed to retrieve new info from test database: %s", err.Error())) + } + + assert.Equal(t, "Pitbull", artist.Name) + assert.Equal(t, "0TnOYISbd1XYRBk9myaseg", artist.SpotifyID) } diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..14dfb22 --- /dev/null +++ b/utils.go @@ -0,0 +1,47 @@ +package main + +import ( + "fmt" + "net/http" + "encoding/json" +) + +type SpotifyResponse struct { + ID string + Name string + Popularity int + Genres []string +} + +// Makes a request to spotify API to grab artist data +// And parse the results into a properly typed struct +func getSpotifyArtistData(artistID string, spotifyAuthToken string) (SpotifyResponse, error) { + + artistEndpoint := fmt.Sprintf("https://api.spotify.com/v1/artists/%s", artistID) + req, err := http.NewRequest("GET", artistEndpoint, nil) + if err != nil { + return SpotifyResponse{}, fmt.Errorf("Failed to build HTTP request to spotify: %w", err) + } + req.Header.Add("Authorization", spotifyAuthToken) + + // Send off request + resp, err := http.DefaultClient.Do(req) + if err != nil { + return SpotifyResponse{}, fmt.Errorf("Failed to get artist data from spotify API: %w", err) + } else if resp.StatusCode != 200 { + return SpotifyResponse{}, fmt.Errorf("Failed to get artist data from spotify API due to response status: %s", resp.Status) + } + + var spotifyResponse SpotifyResponse + err = json.NewDecoder(resp.Body).Decode(&spotifyResponse) + if err != nil { + return SpotifyResponse{}, fmt.Errorf("Failed to read response body data from spotify: %w", err) + } + + // Close this immediately since it's unused now + resp.Body.Close() + + // After all the above checks we assume this response is populated + // If errors arise we can do extra validation here + return spotifyResponse, nil +}