diff --git a/cmd/feelbeatserver/main.go b/cmd/feelbeatserver/main.go index 0a6dc81..45c3d19 100644 --- a/cmd/feelbeatserver/main.go +++ b/cmd/feelbeatserver/main.go @@ -11,6 +11,7 @@ import ( "github.com/feelbeatapp/feelbeatserver/internal/infra/ws" "github.com/feelbeatapp/feelbeatserver/internal/lib/component" "github.com/feelbeatapp/feelbeatserver/internal/lib/roomrepository" + "github.com/feelbeatapp/feelbeatserver/internal/thirdparty/spotify" "github.com/knadh/koanf/v2" ) @@ -41,7 +42,7 @@ func main() { ws.ServeWebsockets(hub, w, r) }) - setupAPI(auth.AuthorizeThroughSpotify, roomrepository.NewInMemoryRoomRepository()) + setupAPI(auth.AuthorizeThroughSpotify, roomrepository.NewInMemoryRoomRepository(spotify.SpotifyApi{})) fblog.Info(component.FeelBeatServer, "Server started", "port", port) log.Fatal(http.ListenAndServe(fmt.Sprintf("localhost:%d", port), nil)) diff --git a/cmd/feelbeatserver/setupapi.go b/cmd/feelbeatserver/setupapi.go index 4df1993..4305614 100644 --- a/cmd/feelbeatserver/setupapi.go +++ b/cmd/feelbeatserver/setupapi.go @@ -10,12 +10,10 @@ import ( const baseUrl = "/api/v1" -// TODO: Add contexts to api handler for graceful shutdown - func setupAPI(authWrapper auth.AuthWrapper, roomRepo roomrepository.RoomRepository) { roomApi := roomapi.NewRoomApi(roomRepo) - handlers := []func(string, auth.AuthWrapper){roomApi.ServeCreateGame} + handlers := []func(string, auth.AuthWrapper){roomApi.ServeCreateGame, roomApi.ServeFetchRooms} fblog.Info(component.Api, "Setting up REST API", "handlers count", len(handlers)) diff --git a/internal/infra/api/roomapi/creategame.go b/internal/infra/api/roomapi/creategame.go index ca97c86..cdf0c42 100644 --- a/internal/infra/api/roomapi/creategame.go +++ b/internal/infra/api/roomapi/creategame.go @@ -18,7 +18,7 @@ type createGameResponse struct { RoomId string `json:"roomId"` } -func (r RoomApi) createGameHandler(userId string, res http.ResponseWriter, req *http.Request) { +func (r RoomApi) createGameHandler(user auth.User, res http.ResponseWriter, req *http.Request) { var payload room.RoomSettings err := api.ParseBody(req.Body, &payload) if err != nil { @@ -27,7 +27,7 @@ func (r RoomApi) createGameHandler(userId string, res http.ResponseWriter, req * return } - roomId, err := r.roomRepo.CreateRoom(userId, payload) + roomId, err := r.roomRepo.CreateRoom(user, payload) if err != nil { var fbError *feelbeaterror.FeelBeatError if errors.As(err, &fbError) { @@ -36,7 +36,7 @@ func (r RoomApi) createGameHandler(userId string, res http.ResponseWriter, req * http.Error(res, feelbeaterror.Default, feelbeaterror.StatusCode(feelbeaterror.Default)) } - api.LogApiError("Create room failed", err, userId, req) + api.LogApiError("Create room failed", err, user.Profile.Id, req) return } @@ -44,18 +44,18 @@ func (r RoomApi) createGameHandler(userId string, res http.ResponseWriter, req * RoomId: roomId, }) if err != nil { - api.LogApiError("Couldn't encode response", err, userId, req) + api.LogApiError("Couldn't encode response", err, user.Profile.Id, req) return } res.Header().Set("Content-Type", "application/json") - _, err = res.Write(resJson) + err = api.SendJsonResponse(&res, resJson) if err != nil { - api.LogApiError("Couldn't write response", err, userId, req) + api.LogApiError("Couldn't write response", err, user.Profile.Id, req) return } - api.LogApiCall(userId, req) + api.LogApiCall(user.Profile.Id, req) } func (r RoomApi) ServeCreateGame(baseUrl string, authWrapper auth.AuthWrapper) { diff --git a/internal/infra/api/roomapi/fetchgames.go b/internal/infra/api/roomapi/fetchgames.go new file mode 100644 index 0000000..88920ae --- /dev/null +++ b/internal/infra/api/roomapi/fetchgames.go @@ -0,0 +1,56 @@ +package roomapi + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/feelbeatapp/feelbeatserver/internal/infra/api" + "github.com/feelbeatapp/feelbeatserver/internal/infra/auth" +) + +type fetchGamesResponse struct { + Rooms []responseRoom `json:"rooms"` +} + +type responseRoom struct { + Id string `json:"id"` + Name string `json:"name"` + Players int `json:"players"` + MaxPlayers int `json:"maxPlayers"` + ImageUrl string `json:"imageUrl"` +} + +func (r RoomApi) fetchRoomsHandler(user auth.User, res http.ResponseWriter, req *http.Request) { + rooms := r.roomRepo.GetAllRooms() + formatted := fetchGamesResponse{ + Rooms: make([]responseRoom, 0), + } + + for _, room := range rooms { + formatted.Rooms = append(formatted.Rooms, responseRoom{ + Id: room.Id(), + Name: room.Name(), + Players: len(room.Players()), + MaxPlayers: room.Settings().MaxPlayers, + ImageUrl: room.ImageUrl(), + }) + } + + resJson, err := json.Marshal(formatted) + if err != nil { + api.LogApiError("Couldn't encode response", err, user.Profile.Id, req) + return + } + err = api.SendJsonResponse(&res, resJson) + if err != nil { + api.LogApiError("Couldn't write response", err, user.Profile.Id, req) + return + } + + api.LogApiCall(user.Profile.Id, req) +} + +func (r RoomApi) ServeFetchRooms(baseUrl string, authWrapper auth.AuthWrapper) { + http.HandleFunc(fmt.Sprintf("%s/rooms", baseUrl), authWrapper(r.fetchRoomsHandler)) +} diff --git a/internal/infra/api/setjsoncontenttype.go b/internal/infra/api/setjsoncontenttype.go new file mode 100644 index 0000000..961bbb7 --- /dev/null +++ b/internal/infra/api/setjsoncontenttype.go @@ -0,0 +1,12 @@ +package api + +import ( + "net/http" +) + +func SendJsonResponse(res *http.ResponseWriter, bytes []byte) error { + (*res).Header().Set("Content-Type", "application/json") + _, err := (*res).Write(bytes) + + return err +} diff --git a/internal/infra/auth/authorizethroughspotify.go b/internal/infra/auth/authorizethroughspotify.go index e1e0efe..c140a03 100644 --- a/internal/infra/auth/authorizethroughspotify.go +++ b/internal/infra/auth/authorizethroughspotify.go @@ -6,10 +6,11 @@ import ( "github.com/feelbeatapp/feelbeatserver/internal/infra/fblog" "github.com/feelbeatapp/feelbeatserver/internal/lib/component" + "github.com/feelbeatapp/feelbeatserver/internal/lib/feelbeaterror" "github.com/feelbeatapp/feelbeatserver/internal/thirdparty/spotify" ) -func AuthorizeThroughSpotify(handler func(string, http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { +func AuthorizeThroughSpotify(handler func(User, http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { return func(res http.ResponseWriter, req *http.Request) { authHeader := req.Header.Get("Authorization") @@ -21,13 +22,14 @@ func AuthorizeThroughSpotify(handler func(string, http.ResponseWriter, *http.Req } token := splits[1] - userId, err := spotify.GetUserId(token) + user, err := spotify.GetUserProfile(token) + if err != nil { - http.Error(res, http.StatusText(http.StatusForbidden), http.StatusForbidden) + http.Error(res, feelbeaterror.AuthFailed, http.StatusForbidden) fblog.Error(component.Auth, "Access denied", "reason", err) return } - handler(userId, res, req) + handler(User{Profile: user, Token: token}, res, req) } } diff --git a/internal/infra/auth/authwrapper.go b/internal/infra/auth/authwrapper.go index 5f98e66..7f02881 100644 --- a/internal/infra/auth/authwrapper.go +++ b/internal/infra/auth/authwrapper.go @@ -1,5 +1,14 @@ package auth -import "net/http" +import ( + "net/http" -type AuthWrapper func(func(string, http.ResponseWriter, *http.Request)) func (http.ResponseWriter, *http.Request) + "github.com/feelbeatapp/feelbeatserver/internal/lib" +) + +type User struct { + Profile lib.UserProfile + Token string +} + +type AuthWrapper func(func(User, http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) diff --git a/internal/lib/feelbeaterror/errorcode.go b/internal/lib/feelbeaterror/errorcode.go index fc470fb..4e4ac22 100644 --- a/internal/lib/feelbeaterror/errorcode.go +++ b/internal/lib/feelbeaterror/errorcode.go @@ -5,7 +5,9 @@ import "net/http" type ErrorCode string const ( - Default = "unexpected_error" + Default = "Unexpected error occurred" + AuthFailed = "Authorization failed" + LoadingPlaylistFailed = "Playlist loading failed" ) func StatusCode(code ErrorCode) int { diff --git a/internal/lib/room/room.go b/internal/lib/room/room.go index b5b3f51..a441d60 100644 --- a/internal/lib/room/room.go +++ b/internal/lib/room/room.go @@ -1,19 +1,45 @@ package room +import ( + "github.com/feelbeatapp/feelbeatserver/internal/lib" +) + type Room struct { id string - ownerId string + playlist lib.PlaylistData + owner lib.UserProfile settings RoomSettings + players []Player +} + +type Player struct { } -func NewRoom(id string, ownerId string, settings RoomSettings) Room { +func NewRoom(id string, playlist lib.PlaylistData, owner lib.UserProfile, settings RoomSettings) Room { return Room{ id: id, + playlist: playlist, + owner: owner, settings: settings, - ownerId: ownerId, + players: make([]Player, 0), } } func (r Room) Id() string { return r.id } + +func (r Room) Name() string { + return r.playlist.Name +} + +func (r Room) Players() []Player { + return r.players +} + +func (r Room) ImageUrl() string { + return r.playlist.ImageUrl +} +func (r Room) Settings() RoomSettings { + return r.settings +} diff --git a/internal/lib/roomrepository/inmemoryroomrepository.go b/internal/lib/roomrepository/inmemoryroomrepository.go index 30ed7e3..f81f1a9 100644 --- a/internal/lib/roomrepository/inmemoryroomrepository.go +++ b/internal/lib/roomrepository/inmemoryroomrepository.go @@ -3,32 +3,47 @@ package roomrepository import ( "fmt" + "github.com/feelbeatapp/feelbeatserver/internal/infra/auth" + "github.com/feelbeatapp/feelbeatserver/internal/lib" "github.com/feelbeatapp/feelbeatserver/internal/lib/room" - "github.com/feelbeatapp/feelbeatserver/internal/lib/validation" "github.com/google/uuid" ) +type SpotifyApi interface { + FetchPlaylistData(playlistId string, token string) (lib.PlaylistData, error) +} + type InMemoryRoomRepository struct { - rooms map[string]room.Room + spotify SpotifyApi + rooms map[string]room.Room } -func NewInMemoryRoomRepository() InMemoryRoomRepository { +func NewInMemoryRoomRepository(spotify SpotifyApi) InMemoryRoomRepository { return InMemoryRoomRepository{ - rooms: make(map[string]room.Room), + spotify: spotify, + rooms: make(map[string]room.Room), } } -// TODO: Implement fetching playlist details from spotify -func (r InMemoryRoomRepository) CreateRoom(ownderId string, settings room.RoomSettings) (string, error) { - err := validation.ValidateRoomSettings(settings) +func (r InMemoryRoomRepository) CreateRoom(user auth.User, settings room.RoomSettings) (string, error) { + playlistData, err := r.spotify.FetchPlaylistData(settings.PlaylistId, user.Token) if err != nil { return "", err } - newRoom := room.NewRoom(uuid.NewString(), ownderId, settings) + newRoom := room.NewRoom(uuid.NewString(), playlistData, user.Profile, settings) r.rooms[newRoom.Id()] = newRoom - fmt.Println(r.rooms) + fmt.Println(newRoom) return newRoom.Id(), nil } + +func (r InMemoryRoomRepository) GetAllRooms() []room.Room { + result := make([]room.Room, 0) + for _, room := range r.rooms { + result = append(result, room) + } + + return result +} diff --git a/internal/lib/roomrepository/roomrepository.go b/internal/lib/roomrepository/roomrepository.go index 23bd8a9..a687603 100644 --- a/internal/lib/roomrepository/roomrepository.go +++ b/internal/lib/roomrepository/roomrepository.go @@ -1,7 +1,11 @@ package roomrepository -import "github.com/feelbeatapp/feelbeatserver/internal/lib/room" +import ( + "github.com/feelbeatapp/feelbeatserver/internal/infra/auth" + "github.com/feelbeatapp/feelbeatserver/internal/lib/room" +) type RoomRepository interface { - CreateRoom(string, room.RoomSettings) (string, error) + CreateRoom(user auth.User, settings room.RoomSettings) (string, error) + GetAllRooms() []room.Room } diff --git a/internal/lib/types.go b/internal/lib/types.go index cbe7d65..48f5220 100644 --- a/internal/lib/types.go +++ b/internal/lib/types.go @@ -7,3 +7,20 @@ type SongDetails struct { Artist string Duration time.Duration } + +type Song struct { + Id string + Details SongDetails +} + +type PlaylistData struct { + Name string + ImageUrl string + Songs []Song +} + +type UserProfile struct { + Id string + Name string + ImageUrl string +} diff --git a/internal/lib/validation/roomsettings.go b/internal/lib/validation/roomsettings.go deleted file mode 100644 index 3bbcede..0000000 --- a/internal/lib/validation/roomsettings.go +++ /dev/null @@ -1,8 +0,0 @@ -package validation - -import "github.com/feelbeatapp/feelbeatserver/internal/lib/room" - -// TODO: Implement validation of room settings -func ValidateRoomSettings(settings room.RoomSettings) error { - return nil -} diff --git a/internal/thirdparty/spotify/getuserprofile.go b/internal/thirdparty/spotify/getuserprofile.go index 853968f..95c85e9 100644 --- a/internal/thirdparty/spotify/getuserprofile.go +++ b/internal/thirdparty/spotify/getuserprofile.go @@ -1,39 +1,56 @@ package spotify import ( + "encoding/json" "fmt" "net/http" - "github.com/buger/jsonparser" "github.com/feelbeatapp/feelbeatserver/internal/infra/api" + "github.com/feelbeatapp/feelbeatserver/internal/lib" ) -// TODO: Rethink error handling in GetUserId +type profileResponse struct { + Id string `json:"id"` + Name string `json:"display_name"` + Images []struct { + Url string `json:"url"` + } `json:"images"` +} -func GetUserId(token string) (string, error) { +func GetUserProfile(token string) (lib.UserProfile, error) { req, err := newGetApiCall("/me", token) if err != nil { - return "", fmt.Errorf("Failed to create user id request: %w", err) + return lib.UserProfile{}, fmt.Errorf("Failed to create user id request: %w", err) } res, err := http.DefaultClient.Do(req) if err != nil { - return "", fmt.Errorf("User id request failed: %w", err) + return lib.UserProfile{}, fmt.Errorf("User id request failed: %w", err) } if res.StatusCode != http.StatusOK { - return "", fmt.Errorf("Auth request failed: %s", res.Status) + return lib.UserProfile{}, fmt.Errorf("Auth request failed: %s", res.Status) } bytes, err := api.ReadBody(res.Body) if err != nil { - return "", fmt.Errorf("Couldn't read user id request body: %w", err) + return lib.UserProfile{}, fmt.Errorf("Couldn't read user id request body: %w", err) } - userid, err := jsonparser.GetString(bytes, "id") + var response profileResponse + err = json.Unmarshal(bytes, &response) if err != nil { - return "", fmt.Errorf("Failed to parse user profile: %w", err) + return lib.UserProfile{}, fmt.Errorf("Failed to parse user profile: %w", err) + } + + var imageUrl string + if imageUrl = ""; len(response.Images) > 0 { + imageUrl = response.Images[0].Url } - return userid, nil + return lib.UserProfile{ + Id: response.Id, + Name: response.Name, + ImageUrl: imageUrl, + }, nil } diff --git a/internal/thirdparty/spotify/playlistdataresponse.go b/internal/thirdparty/spotify/playlistdataresponse.go new file mode 100644 index 0000000..5ee84d7 --- /dev/null +++ b/internal/thirdparty/spotify/playlistdataresponse.go @@ -0,0 +1,20 @@ +package spotify + +type playlistDataResponse struct { + Name string `json:"name"` + Images []struct { + Url string `json:"url"` + } `json:"images"` + Tracks struct { + Items []struct { + Track struct { + Artists []struct { + Name string `json:"name"` + } `json:"artists"` + Name string `json:"name"` + ID string `json:"id"` + DurationMs int `json:"duration_ms"` + } `json:"track"` + } `json:"items"` + } `json:"tracks"` +} diff --git a/internal/thirdparty/spotify/spotifyapi.go b/internal/thirdparty/spotify/spotifyapi.go index b594c62..e5a2e9d 100644 --- a/internal/thirdparty/spotify/spotifyapi.go +++ b/internal/thirdparty/spotify/spotifyapi.go @@ -1,47 +1,86 @@ package spotify import ( + "encoding/json" "fmt" - "io" - "log" "net/http" + "strings" "time" - "github.com/buger/jsonparser" + "github.com/feelbeatapp/feelbeatserver/internal/infra/api" "github.com/feelbeatapp/feelbeatserver/internal/lib" + "github.com/feelbeatapp/feelbeatserver/internal/lib/feelbeaterror" ) -func FetchSongDetails(spotifyId string, token string) { - url := fmt.Sprintf("https://api.spotify.com/v1/tracks/%s", spotifyId) - req, err := http.NewRequest("GET", url, nil) +type SpotifyApi struct { +} + +func (s SpotifyApi) FetchPlaylistData(plalistId string, token string) (lib.PlaylistData, error) { + url := fmt.Sprintf("/playlists/%s?additional_types=track&fields=name,images(url),tracks(items(track(id,images,name,artists(name),duration_ms)))", plalistId) + req, err := newGetApiCall(url, token) if err != nil { - return + return lib.PlaylistData{}, &feelbeaterror.FeelBeatError{ + DebugMessage: err.Error(), + UserMessage: feelbeaterror.LoadingPlaylistFailed, + } } - req.Header.Set("Authorization", "Bearer "+token) res, err := http.DefaultClient.Do(req) if err != nil { - log.Fatal(err) + return lib.PlaylistData{}, &feelbeaterror.FeelBeatError{ + DebugMessage: err.Error(), + UserMessage: feelbeaterror.LoadingPlaylistFailed, + } } - defer res.Body.Close() - bytes, err := io.ReadAll(res.Body) + bytes, err := api.ReadBody(res.Body) if err != nil { - log.Fatal(err) + return lib.PlaylistData{}, &feelbeaterror.FeelBeatError{ + DebugMessage: err.Error(), + UserMessage: feelbeaterror.LoadingPlaylistFailed, + } } - - title, err := jsonparser.GetString(bytes, "name") + var songsResponse playlistDataResponse + err = json.Unmarshal(bytes, &songsResponse) if err != nil { - log.Fatal(err) + return lib.PlaylistData{}, &feelbeaterror.FeelBeatError{ + DebugMessage: err.Error(), + UserMessage: feelbeaterror.LoadingPlaylistFailed, + } + } + if len(songsResponse.Tracks.Items) == 0 { + return lib.PlaylistData{}, &feelbeaterror.FeelBeatError{ + DebugMessage: "No songs in playlist", + UserMessage: feelbeaterror.LoadingPlaylistFailed, + } } - durationInMs, err := jsonparser.GetInt(bytes, "duration_ms") - if err != nil { - log.Fatal(err) + + songs := make([]lib.Song, 0, len(songsResponse.Tracks.Items)) + for _, item := range songsResponse.Tracks.Items { + artistNames := make([]string, 0, len(item.Track.Artists)) + for _, a := range item.Track.Artists { + artistNames = append(artistNames, a.Name) + } + + songs = append(songs, lib.Song{ + Id: item.Track.ID, + Details: lib.SongDetails{ + Title: item.Track.Name, + Artist: strings.Join(artistNames, " "), + Duration: time.Duration(item.Track.DurationMs) * time.Millisecond, + }, + }) + } + + var imageUrl string + if imageUrl = ""; len(songsResponse.Images) > 0 { + imageUrl = songsResponse.Images[0].Url } - fmt.Println(lib.SongDetails{ - Title: title, - Duration: time.Duration(durationInMs) * time.Millisecond, - }) + return lib.PlaylistData{ + Name: songsResponse.Name, + ImageUrl: imageUrl, + Songs: songs, + }, nil }