diff --git a/cmd/feelbeatserver/main.go b/cmd/feelbeatserver/main.go index 125c74f..0a6dc81 100644 --- a/cmd/feelbeatserver/main.go +++ b/cmd/feelbeatserver/main.go @@ -6,9 +6,11 @@ import ( "net/http" "os" + "github.com/feelbeatapp/feelbeatserver/internal/infra/auth" "github.com/feelbeatapp/feelbeatserver/internal/infra/fblog" "github.com/feelbeatapp/feelbeatserver/internal/infra/ws" "github.com/feelbeatapp/feelbeatserver/internal/lib/component" + "github.com/feelbeatapp/feelbeatserver/internal/lib/roomrepository" "github.com/knadh/koanf/v2" ) @@ -39,6 +41,8 @@ func main() { ws.ServeWebsockets(hub, w, r) }) + setupAPI(auth.AuthorizeThroughSpotify, roomrepository.NewInMemoryRoomRepository()) + 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 new file mode 100644 index 0000000..4df1993 --- /dev/null +++ b/cmd/feelbeatserver/setupapi.go @@ -0,0 +1,25 @@ +package main + +import ( + "github.com/feelbeatapp/feelbeatserver/internal/infra/api/roomapi" + "github.com/feelbeatapp/feelbeatserver/internal/infra/auth" + "github.com/feelbeatapp/feelbeatserver/internal/infra/fblog" + "github.com/feelbeatapp/feelbeatserver/internal/lib/component" + "github.com/feelbeatapp/feelbeatserver/internal/lib/roomrepository" +) + +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} + + fblog.Info(component.Api, "Setting up REST API", "handlers count", len(handlers)) + + for _, f := range handlers { + f(baseUrl, authWrapper) + } +} diff --git a/go.mod b/go.mod index 3935de7..281a448 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require github.com/gorilla/websocket v1.5.3 require ( github.com/buger/jsonparser v1.1.1 + github.com/google/uuid v1.6.0 github.com/knadh/koanf/parsers/toml v0.1.0 github.com/knadh/koanf/providers/env v1.0.0 github.com/knadh/koanf/providers/file v1.1.2 @@ -26,7 +27,6 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect golang.org/x/crypto v0.21.0 // indirect golang.org/x/sys v0.28.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 5f7779b..99004fd 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/ github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= @@ -40,8 +42,6 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/internal/infra/api/logapicall.go b/internal/infra/api/logapicall.go new file mode 100644 index 0000000..27676ba --- /dev/null +++ b/internal/infra/api/logapicall.go @@ -0,0 +1,16 @@ +package api + +import ( + "net/http" + + "github.com/feelbeatapp/feelbeatserver/internal/infra/fblog" + "github.com/feelbeatapp/feelbeatserver/internal/lib/component" +) + +func LogApiCall(userId string, req *http.Request) { + fblog.Info(component.Api, req.Method+" "+req.URL.String(), "user", userId, "ip", req.RemoteAddr) +} + +func LogApiError(message string, err error, userId string, req *http.Request) { + fblog.Error(component.Api, req.Method+" "+req.URL.String()+": "+message, "err", err, "user", userId, "ip", req.RemoteAddr) +} diff --git a/internal/infra/api/parsebody.go b/internal/infra/api/parsebody.go new file mode 100644 index 0000000..92c53b5 --- /dev/null +++ b/internal/infra/api/parsebody.go @@ -0,0 +1,15 @@ +package api + +import ( + "encoding/json" + "io" +) + +func ParseBody(body io.ReadCloser, out any) error { + bytes, err := ReadBody(body) + if err != nil { + return err + } + + return json.Unmarshal(bytes, out) +} diff --git a/internal/infra/api/readbody.go b/internal/infra/api/readbody.go new file mode 100644 index 0000000..d252e3b --- /dev/null +++ b/internal/infra/api/readbody.go @@ -0,0 +1,16 @@ +package api + +import ( + "io" +) + +func ReadBody(body io.ReadCloser) ([]byte, error) { + defer body.Close() + + bytes, err := io.ReadAll(body) + if err != nil { + return []byte{}, err + } + + return bytes, nil +} diff --git a/internal/infra/api/roomapi/creategame.go b/internal/infra/api/roomapi/creategame.go new file mode 100644 index 0000000..ca97c86 --- /dev/null +++ b/internal/infra/api/roomapi/creategame.go @@ -0,0 +1,63 @@ +package roomapi + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/feelbeatapp/feelbeatserver/internal/infra/api" + "github.com/feelbeatapp/feelbeatserver/internal/infra/auth" + "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/lib/room" +) + +type createGameResponse struct { + RoomId string `json:"roomId"` +} + +func (r RoomApi) createGameHandler(userId string, res http.ResponseWriter, req *http.Request) { + var payload room.RoomSettings + err := api.ParseBody(req.Body, &payload) + if err != nil { + http.Error(res, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + fblog.Error(component.Api, "Error: ", "err", err) + return + } + + roomId, err := r.roomRepo.CreateRoom(userId, payload) + if err != nil { + var fbError *feelbeaterror.FeelBeatError + if errors.As(err, &fbError) { + http.Error(res, string(fbError.UserMessage), feelbeaterror.StatusCode(fbError.UserMessage)) + } else { + http.Error(res, feelbeaterror.Default, feelbeaterror.StatusCode(feelbeaterror.Default)) + } + + api.LogApiError("Create room failed", err, userId, req) + return + } + + resJson, err := json.Marshal(createGameResponse{ + RoomId: roomId, + }) + if err != nil { + api.LogApiError("Couldn't encode response", err, userId, req) + return + } + + res.Header().Set("Content-Type", "application/json") + _, err = res.Write(resJson) + if err != nil { + api.LogApiError("Couldn't write response", err, userId, req) + return + } + + api.LogApiCall(userId, req) +} + +func (r RoomApi) ServeCreateGame(baseUrl string, authWrapper auth.AuthWrapper) { + http.HandleFunc(fmt.Sprintf("%s/create", baseUrl), authWrapper(r.createGameHandler)) +} diff --git a/internal/infra/api/roomapi/roomapi.go b/internal/infra/api/roomapi/roomapi.go new file mode 100644 index 0000000..dad01e7 --- /dev/null +++ b/internal/infra/api/roomapi/roomapi.go @@ -0,0 +1,15 @@ +package roomapi + +import ( + "github.com/feelbeatapp/feelbeatserver/internal/lib/roomrepository" +) + +type RoomApi struct { + roomRepo roomrepository.RoomRepository +} + +func NewRoomApi(roomRepo roomrepository.RoomRepository) RoomApi { + return RoomApi{ + roomRepo: roomRepo, + } +} diff --git a/internal/infra/auth/authorizethroughspotify.go b/internal/infra/auth/authorizethroughspotify.go new file mode 100644 index 0000000..e1e0efe --- /dev/null +++ b/internal/infra/auth/authorizethroughspotify.go @@ -0,0 +1,33 @@ +package auth + +import ( + "net/http" + "strings" + + "github.com/feelbeatapp/feelbeatserver/internal/infra/fblog" + "github.com/feelbeatapp/feelbeatserver/internal/lib/component" + "github.com/feelbeatapp/feelbeatserver/internal/thirdparty/spotify" +) + +func AuthorizeThroughSpotify(handler func(string, http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { + return func(res http.ResponseWriter, req *http.Request) { + authHeader := req.Header.Get("Authorization") + + splits := strings.Split(authHeader, "Bearer ") + if len(splits) != 2 { + http.Error(res, "Incorrect authorization format", http.StatusBadRequest) + fblog.Error(component.Auth, "Incorrect authorization format", "url", req.URL, "addr", req.RemoteAddr) + return + } + token := splits[1] + + userId, err := spotify.GetUserId(token) + if err != nil { + http.Error(res, http.StatusText(http.StatusForbidden), http.StatusForbidden) + fblog.Error(component.Auth, "Access denied", "reason", err) + return + } + + handler(userId, res, req) + } +} diff --git a/internal/infra/auth/authwrapper.go b/internal/infra/auth/authwrapper.go new file mode 100644 index 0000000..5f98e66 --- /dev/null +++ b/internal/infra/auth/authwrapper.go @@ -0,0 +1,5 @@ +package auth + +import "net/http" + +type AuthWrapper func(func(string, http.ResponseWriter, *http.Request)) func (http.ResponseWriter, *http.Request) diff --git a/internal/infra/ws/servewebsockets.go b/internal/infra/ws/servewebsockets.go index 06c7dd1..ad8b7bd 100644 --- a/internal/infra/ws/servewebsockets.go +++ b/internal/infra/ws/servewebsockets.go @@ -15,7 +15,7 @@ var upgrader = websocket.Upgrader{ }, } -func ServeWebsockets(hub *BasicHub, w http.ResponseWriter, r *http.Request) { +func ServeWebsockets(hub *WSHub, w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) fblog.Info(component.WebSocket, "received new connection", "ip", r.RemoteAddr) diff --git a/internal/infra/ws/hub.go b/internal/infra/ws/wshub.go similarity index 80% rename from internal/infra/ws/hub.go rename to internal/infra/ws/wshub.go index 2ebe45a..5af5f62 100644 --- a/internal/infra/ws/hub.go +++ b/internal/infra/ws/wshub.go @@ -5,7 +5,7 @@ import ( "github.com/feelbeatapp/feelbeatserver/internal/infra/fblog" ) -type BasicHub struct { +type WSHub struct { clients map[HubClient]bool broadcast chan ClientMessage register chan HubClient @@ -13,8 +13,8 @@ type BasicHub struct { exit chan bool } -func NewHub() *BasicHub { - return &BasicHub{ +func NewHub() *WSHub { + return &WSHub{ clients: make(map[HubClient]bool), broadcast: make(chan ClientMessage), register: make(chan HubClient), @@ -23,7 +23,7 @@ func NewHub() *BasicHub { } } -func (h *BasicHub) Run() { +func (h *WSHub) Run() { defer func() { for c := range h.clients { c.Close() @@ -55,18 +55,18 @@ func (h *BasicHub) Run() { } } -func (h *BasicHub) RegisterClient(client HubClient) { +func (h *WSHub) RegisterClient(client HubClient) { h.register <- client } -func (h *BasicHub) Broadcast(message ClientMessage) { +func (h *WSHub) Broadcast(message ClientMessage) { h.broadcast <- message } -func (h *BasicHub) UnregisterClient(client HubClient) { +func (h *WSHub) UnregisterClient(client HubClient) { h.unregister <- client } -func (h *BasicHub) Stop() { +func (h *WSHub) Stop() { h.exit <- true } diff --git a/internal/lib/component/main.go b/internal/lib/component/main.go index 8bf7246..bb515e3 100644 --- a/internal/lib/component/main.go +++ b/internal/lib/component/main.go @@ -2,10 +2,12 @@ package component const ( FeelBeatServer = "FeelBeatServer" - Config = "config" - WebSocket = "websocket" - Client = "client" - Hub = "hub" + Config = "config" + WebSocket = "websocket" + Client = "client" + Hub = "hub" + Api = "api" + Auth = "auth" AudioDownloadTask = "audiodownloadtask" ) diff --git a/internal/lib/feelbeaterror/errorcode.go b/internal/lib/feelbeaterror/errorcode.go new file mode 100644 index 0000000..fc470fb --- /dev/null +++ b/internal/lib/feelbeaterror/errorcode.go @@ -0,0 +1,16 @@ +package feelbeaterror + +import "net/http" + +type ErrorCode string + +const ( + Default = "unexpected_error" +) + +func StatusCode(code ErrorCode) int { + switch code { + default: + return http.StatusInternalServerError + } +} diff --git a/internal/lib/feelbeaterror/feelbeaterror.go b/internal/lib/feelbeaterror/feelbeaterror.go new file mode 100644 index 0000000..51a60ba --- /dev/null +++ b/internal/lib/feelbeaterror/feelbeaterror.go @@ -0,0 +1,12 @@ +package feelbeaterror + +import "fmt" + +type FeelBeatError struct { + DebugMessage string + UserMessage ErrorCode +} + +func (e *FeelBeatError) Error() string { + return fmt.Sprintf("%s: %s", e.UserMessage, e.DebugMessage) +} diff --git a/internal/lib/room/room.go b/internal/lib/room/room.go new file mode 100644 index 0000000..b5b3f51 --- /dev/null +++ b/internal/lib/room/room.go @@ -0,0 +1,19 @@ +package room + +type Room struct { + id string + ownerId string + settings RoomSettings +} + +func NewRoom(id string, ownerId string, settings RoomSettings) Room { + return Room{ + id: id, + settings: settings, + ownerId: ownerId, + } +} + +func (r Room) Id() string { + return r.id +} diff --git a/internal/lib/room/roomsettings.go b/internal/lib/room/roomsettings.go new file mode 100644 index 0000000..217e12c --- /dev/null +++ b/internal/lib/room/roomsettings.go @@ -0,0 +1,10 @@ +package room + +type RoomSettings struct { + MaxPlayers int `json:"maxPlayers"` + TurnCount int `json:"turnCount"` + TimePenaltyPerSecond int `json:"timePenaltyPerSecond"` + BasePoints int `json:"basePoints"` + IncorrectGuessPenalty int `json:"incorrectGuessPenalty"` + PlaylistId string `json:"playlistId"` +} diff --git a/internal/lib/roomrepository/inmemoryroomrepository.go b/internal/lib/roomrepository/inmemoryroomrepository.go new file mode 100644 index 0000000..30ed7e3 --- /dev/null +++ b/internal/lib/roomrepository/inmemoryroomrepository.go @@ -0,0 +1,34 @@ +package roomrepository + +import ( + "fmt" + + "github.com/feelbeatapp/feelbeatserver/internal/lib/room" + "github.com/feelbeatapp/feelbeatserver/internal/lib/validation" + "github.com/google/uuid" +) + +type InMemoryRoomRepository struct { + rooms map[string]room.Room +} + +func NewInMemoryRoomRepository() InMemoryRoomRepository { + return InMemoryRoomRepository{ + 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) + if err != nil { + return "", err + } + + newRoom := room.NewRoom(uuid.NewString(), ownderId, settings) + r.rooms[newRoom.Id()] = newRoom + + fmt.Println(r.rooms) + + return newRoom.Id(), nil +} diff --git a/internal/lib/roomrepository/roomrepository.go b/internal/lib/roomrepository/roomrepository.go new file mode 100644 index 0000000..23bd8a9 --- /dev/null +++ b/internal/lib/roomrepository/roomrepository.go @@ -0,0 +1,7 @@ +package roomrepository + +import "github.com/feelbeatapp/feelbeatserver/internal/lib/room" + +type RoomRepository interface { + CreateRoom(string, room.RoomSettings) (string, error) +} diff --git a/internal/lib/validation/roomsettings.go b/internal/lib/validation/roomsettings.go new file mode 100644 index 0000000..3bbcede --- /dev/null +++ b/internal/lib/validation/roomsettings.go @@ -0,0 +1,8 @@ +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/apicall.go b/internal/thirdparty/spotify/apicall.go new file mode 100644 index 0000000..e31cc3e --- /dev/null +++ b/internal/thirdparty/spotify/apicall.go @@ -0,0 +1,14 @@ +package spotify + +import "net/http" + +func newGetApiCall(path string, token string) (*http.Request, error) { + req, err := http.NewRequest("GET", apiUrl+path, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+token) + + return req, nil +} diff --git a/internal/thirdparty/spotify/apiurl.go b/internal/thirdparty/spotify/apiurl.go new file mode 100644 index 0000000..71de8aa --- /dev/null +++ b/internal/thirdparty/spotify/apiurl.go @@ -0,0 +1,3 @@ +package spotify + +const apiUrl = "https://api.spotify.com/v1" diff --git a/internal/thirdparty/spotify/getuserprofile.go b/internal/thirdparty/spotify/getuserprofile.go new file mode 100644 index 0000000..853968f --- /dev/null +++ b/internal/thirdparty/spotify/getuserprofile.go @@ -0,0 +1,39 @@ +package spotify + +import ( + "fmt" + "net/http" + + "github.com/buger/jsonparser" + "github.com/feelbeatapp/feelbeatserver/internal/infra/api" +) + +// TODO: Rethink error handling in GetUserId + +func GetUserId(token string) (string, error) { + req, err := newGetApiCall("/me", token) + if err != nil { + return "", 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) + } + + if res.StatusCode != http.StatusOK { + return "", 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) + } + + userid, err := jsonparser.GetString(bytes, "id") + if err != nil { + return "", fmt.Errorf("Failed to parse user profile: %w", err) + } + + return userid, nil +}