From a6ca8de13e0b99f942f5bcd1a9e168b00b86556b Mon Sep 17 00:00:00 2001 From: Howl Date: Mon, 15 Aug 2016 19:59:46 +0200 Subject: [PATCH] Implement GET scores in official ripple api --- app/start.go | 14 +++++ app/v1/badge.go | 4 +- app/v1/beatmap.go | 6 +- app/v1/blog.go | 14 ++--- app/v1/doc.go | 6 +- app/v1/errors.go | 2 +- app/v1/friend.go | 8 +-- app/v1/leaderboard.go | 4 +- app/v1/manage_user.go | 2 +- app/v1/score.go | 115 ++++++++++++++++++++++++++++++++++++++ app/v1/token.go | 4 +- app/v1/user.go | 25 ++++----- app/v1/user_scores.go | 22 +++----- common/method_data.go | 5 ++ common/osu_time_format.go | 56 +++++++++++++++++++ common/sort.go | 52 +++++++++++++++++ 16 files changed, 286 insertions(+), 53 deletions(-) create mode 100644 app/v1/score.go create mode 100644 common/sort.go diff --git a/app/start.go b/app/start.go index d20881c..01695dd 100644 --- a/app/start.go +++ b/app/start.go @@ -11,6 +11,7 @@ import ( "github.com/gin-gonic/contrib/gzip" "github.com/gin-gonic/gin" "github.com/jmoiron/sqlx" + "github.com/serenize/snaker" ) var ( @@ -18,11 +19,23 @@ var ( cf common.Conf ) +var commonClusterfucks = map[string]string{ + "RegisteredOn": "register_datetime", + "UsernameAKA": "username_aka", +} + // Start begins taking HTTP connections. func Start(conf common.Conf, dbO *sqlx.DB) *gin.Engine { db = dbO cf = conf + db.MapperFunc(func(s string) string { + if x, ok := commonClusterfucks[s]; ok { + return x + } + return snaker.CamelToSnake(s) + }) + setUpLimiter() r := gin.Default() @@ -68,6 +81,7 @@ func Start(conf common.Conf, dbO *sqlx.DB) *gin.Engine { gv1.GET("/tokens/self", Method(v1.TokenSelfGET)) gv1.GET("/blog/posts", Method(v1.BlogPostsGET)) gv1.GET("/blog/posts/content", Method(v1.BlogPostsContentGET)) + gv1.GET("/scores", Method(v1.ScoresGET)) // ReadConfidential privilege required gv1.GET("/friends", Method(v1.FriendsGET, common.PrivilegeReadConfidential)) diff --git a/app/v1/badge.go b/app/v1/badge.go index 33eac1f..e444ba2 100644 --- a/app/v1/badge.go +++ b/app/v1/badge.go @@ -24,9 +24,9 @@ func BadgesGET(md common.MethodData) common.CodeMessager { rows *sql.Rows err error ) - if md.C.Query("id") != "" { + if md.Query("id") != "" { // TODO(howl): ID validation - rows, err = md.DB.Query("SELECT id, name, icon FROM badges WHERE id = ?", md.C.Query("id")) + rows, err = md.DB.Query("SELECT id, name, icon FROM badges WHERE id = ?", md.Query("id")) } else { rows, err = md.DB.Query("SELECT id, name, icon FROM badges") } diff --git a/app/v1/beatmap.go b/app/v1/beatmap.go index 9896813..0187829 100644 --- a/app/v1/beatmap.go +++ b/app/v1/beatmap.go @@ -87,14 +87,14 @@ func BeatmapSetStatusPOST(md common.MethodData) common.CodeMessager { // BeatmapGET retrieves a beatmap. func BeatmapGET(md common.MethodData) common.CodeMessager { - if md.C.Query("s") == "" && md.C.Query("b") == "" { + if md.Query("s") == "" && md.Query("b") == "" { return common.SimpleResponse(400, "Must pass either querystring param 'b' or 's'") } - setID := common.Int(md.C.Query("s")) + setID := common.Int(md.Query("s")) if setID != 0 { return getSet(md, setID) } - beatmapID := common.Int(md.C.Query("b")) + beatmapID := common.Int(md.Query("b")) if beatmapID != 0 { return getBeatmap(md, beatmapID) } diff --git a/app/v1/blog.go b/app/v1/blog.go index da99f73..1cb3add 100644 --- a/app/v1/blog.go +++ b/app/v1/blog.go @@ -23,9 +23,9 @@ type blogPostsResponse struct { func BlogPostsGET(md common.MethodData) common.CodeMessager { var and string var params []interface{} - if md.C.Query("id") != "" { + if md.Query("id") != "" { and = "b.id = ?" - params = append(params, md.C.Query("id")) + params = append(params, md.Query("id")) } rows, err := md.DB.Query(` SELECT @@ -37,7 +37,7 @@ func BlogPostsGET(md common.MethodData) common.CodeMessager { LEFT JOIN users u ON b.author = u.id LEFT JOIN users_stats s ON b.author = s.id WHERE status = "published" `+and+` - ORDER BY b.id DESC `+common.Paginate(md.C.Query("p"), md.C.Query("l"), 50), params...) + ORDER BY b.id DESC `+common.Paginate(md.Query("p"), md.Query("l"), 50), params...) if err != nil { md.Err(err) return Err500 @@ -79,12 +79,12 @@ func BlogPostsContentGET(md common.MethodData) common.CodeMessager { val string ) switch { - case md.C.Query("slug") != "": + case md.Query("slug") != "": by = "slug" - val = md.C.Query("slug") - case md.C.Query("id") != "": + val = md.Query("slug") + case md.Query("id") != "": by = "id" - val = md.C.Query("id") + val = md.Query("id") default: return ErrMissingField("id|slug") } diff --git a/app/v1/doc.go b/app/v1/doc.go index 5b1009c..734142c 100644 --- a/app/v1/doc.go +++ b/app/v1/doc.go @@ -21,7 +21,7 @@ type docResponse struct { // DocGET retrieves a list of documentation files. func DocGET(md common.MethodData) common.CodeMessager { var wc string - if !md.User.Privileges.HasPrivilegeBlog() || md.C.Query("public") == "1" { + if !md.User.Privileges.HasPrivilegeBlog() || md.Query("public") == "1" { wc = "WHERE public = '1'" } rows, err := md.DB.Query("SELECT id, doc_name, public, is_rule FROM docs " + wc) @@ -50,12 +50,12 @@ type docContentResponse struct { // DocContentGET retrieves the raw markdown file of a doc file func DocContentGET(md common.MethodData) common.CodeMessager { - docID := common.Int(md.C.Query("id")) + docID := common.Int(md.Query("id")) if docID == 0 { return common.SimpleResponse(404, "Documentation file not found!") } var wc string - if !md.User.Privileges.HasPrivilegeBlog() || md.C.Query("public") == "1" { + if !md.User.Privileges.HasPrivilegeBlog() || md.Query("public") == "1" { wc = "AND public = '1'" } var r docContentResponse diff --git a/app/v1/errors.go b/app/v1/errors.go index 2021a13..9fb38fd 100644 --- a/app/v1/errors.go +++ b/app/v1/errors.go @@ -16,6 +16,6 @@ var ( func ErrMissingField(missingFields ...string) common.CodeMessager { return common.ResponseBase{ Code: 422, // http://stackoverflow.com/a/10323055/5328069 - Message: "Missing fields: " + strings.Join(missingFields, ", ") + ".", + Message: "Missing parameters: " + strings.Join(missingFields, ", ") + ".", } } diff --git a/app/v1/friend.go b/app/v1/friend.go index e5d51d3..386378b 100644 --- a/app/v1/friend.go +++ b/app/v1/friend.go @@ -54,7 +54,7 @@ ON users_relationships.user2=users_stats.id WHERE users_relationships.user1=? ORDER BY users_relationships.id` - results, err := md.DB.Query(myFriendsQuery+common.Paginate(md.C.Query("p"), md.C.Query("l"), 50), md.ID()) + results, err := md.DB.Query(myFriendsQuery+common.Paginate(md.Query("p"), md.Query("l"), 50), md.ID()) if err != nil { md.Err(err) return Err500 @@ -105,7 +105,7 @@ type friendsWithResponse struct { func FriendsWithGET(md common.MethodData) common.CodeMessager { var r friendsWithResponse r.Code = 200 - uid := common.Int(md.C.Query("id")) + uid := common.Int(md.Query("id")) if uid == 0 { return r } @@ -122,7 +122,7 @@ func FriendsWithGET(md common.MethodData) common.CodeMessager { // FriendsAddGET is the GET version of FriendsAddPOST. func FriendsAddGET(md common.MethodData) common.CodeMessager { - return addFriend(md, common.Int(md.C.Query("id"))) + return addFriend(md, common.Int(md.Query("id"))) } func addFriend(md common.MethodData, u int) common.CodeMessager { @@ -166,7 +166,7 @@ func userExists(md common.MethodData, u int) (r bool) { // FriendsDelGET is the GET version of FriendDelPOST. func FriendsDelGET(md common.MethodData) common.CodeMessager { - return delFriend(md, common.Int(md.C.Query("id"))) + return delFriend(md, common.Int(md.Query("id"))) } func delFriend(md common.MethodData, u int) common.CodeMessager { diff --git a/app/v1/leaderboard.go b/app/v1/leaderboard.go index a512eba..1f881a0 100644 --- a/app/v1/leaderboard.go +++ b/app/v1/leaderboard.go @@ -35,9 +35,9 @@ INNER JOIN users_stats ON users_stats.id = leaderboard_%[1]s.user // LeaderboardGET gets the leaderboard. func LeaderboardGET(md common.MethodData) common.CodeMessager { - m := getMode(md.C.Query("mode")) + m := getMode(md.Query("mode")) query := fmt.Sprintf(lbUserQuery, m, `WHERE users.privileges & 1 > 0 ORDER BY leaderboard_`+m+`.position `+ - common.Paginate(md.C.Query("p"), md.C.Query("l"), 100)) + common.Paginate(md.Query("p"), md.Query("l"), 100)) rows, err := md.DB.Query(query) if err != nil { md.Err(err) diff --git a/app/v1/manage_user.go b/app/v1/manage_user.go index 99efacf..dea9d9a 100644 --- a/app/v1/manage_user.go +++ b/app/v1/manage_user.go @@ -50,5 +50,5 @@ LEFT JOIN users_stats ON users.id=users_stats.id WHERE users.id=? LIMIT 1` - return userPuts(md, md.DB.QueryRow(query, data.UserID)) + return userPuts(md, md.DB.QueryRowx(query, data.UserID)) } diff --git a/app/v1/score.go b/app/v1/score.go new file mode 100644 index 0000000..0512b44 --- /dev/null +++ b/app/v1/score.go @@ -0,0 +1,115 @@ +package v1 + +import ( + "database/sql" + + "git.zxq.co/ripple/rippleapi/common" +) + +type score struct { + ID int `json:"id"` + BeatmapMD5 string `json:"beatmap_md5"` + Score int64 `json:"score"` + MaxCombo int `json:"max_combo"` + FullCombo bool `json:"full_combo"` + Mods int `json:"mods"` + Count300 int `json:"count_300"` + Count100 int `json:"count_100"` + Count50 int `json:"count_50"` + CountGeki int `json:"count_geki"` + CountKatu int `json:"count_katu"` + CountMiss int `json:"count_miss"` + Time common.OsuTime `json:"time"` + PlayMode int `json:"play_mode"` + Accuracy float64 `json:"accuracy"` + PP float32 `json:"pp"` + Completed int `json:"completed"` +} + +// beatmapScore is to differentiate from userScore, as beatmapScore contains +// also an user, while userScore contains the beatmap. +type beatmapScore struct { + score + User userData `json:"user"` +} + +type scoresResponse struct { + common.ResponseBase + Scores []beatmapScore `json:"scores"` +} + +// ScoresGET retrieves the top scores for a certain beatmap. +func ScoresGET(md common.MethodData) common.CodeMessager { + var ( + beatmapMD5 string + r scoresResponse + ) + switch { + case md.Query("md5") != "": + beatmapMD5 = md.Query("md5") + case md.Query("b") != "": + err := md.DB.Get(&beatmapMD5, "SELECT beatmap_md5 FROM beatmaps WHERE beatmap_id = ? LIMIT 1", md.Query("b")) + switch { + case err == sql.ErrNoRows: + r.Code = 200 + return r + case err != nil: + md.Err(err) + return Err500 + } + default: + return ErrMissingField("md5|b") + } + + sort := common.Sort(md, common.SortConfiguration{ + Default: "scores.pp DESC, scores.score DESC", + Table: "scores", + Allowed: []string{"pp", "score", "accuracy", "time", "id"}, + }) + + rows, err := md.DB.Query(` +SELECT + scores.id, scores.beatmap_md5, scores.score, + scores.max_combo, scores.full_combo, scores.mods, + scores.300_count, scores.100_count, scores.50_count, + scores.gekis_count, scores.katus_count, scores.misses_count, + scores.time, scores.play_mode, scores.accuracy, scores.pp, + scores.completed, + + users.id, users.username, users.register_datetime, users.privileges, + users.latest_activity, users_stats.username_aka, users_stats.country +FROM scores +INNER JOIN users ON users.id = scores.userid +INNER JOIN users_stats ON users_stats.id = scores.userid +WHERE scores.beatmap_md5 = ? AND scores.completed = '3' +`+sort+common.Paginate(md.Query("p"), md.Query("l"), 100), beatmapMD5) + if err != nil { + md.Err(err) + return Err500 + } + for rows.Next() { + var ( + s beatmapScore + u userData + ) + err := rows.Scan( + &s.ID, &s.BeatmapMD5, &s.Score, + &s.MaxCombo, &s.FullCombo, &s.Mods, + &s.Count300, &s.Count100, &s.Count50, + &s.CountGeki, &s.CountKatu, &s.CountMiss, + &s.Time, &s.PlayMode, &s.Accuracy, &s.PP, + &s.Completed, + + &u.ID, &u.Username, &u.RegisteredOn, &u.Privileges, + &u.LatestActivity, &u.UsernameAKA, &u.Country, + ) + if err != nil { + md.Err(err) + continue + } + s.User = u + r.Scores = append(r.Scores, s) + } + r.Code = 200 + return r +} diff --git a/app/v1/token.go b/app/v1/token.go index c46ae0b..96399d8 100644 --- a/app/v1/token.go +++ b/app/v1/token.go @@ -198,8 +198,8 @@ func TokenSelfGET(md common.MethodData) common.CodeMessager { // TokenFixPrivilegesGET fixes the privileges on the token of the given user, // or of all the users if no user is given. func TokenFixPrivilegesGET(md common.MethodData) common.CodeMessager { - id := common.Int(md.C.Query("id")) - if md.C.Query("id") == "self" { + id := common.Int(md.Query("id")) + if md.Query("id") == "self" { id = md.ID() } go fixPrivileges(id, md.DB) diff --git a/app/v1/user.go b/app/v1/user.go index 9105561..166259f 100644 --- a/app/v1/user.go +++ b/app/v1/user.go @@ -6,6 +6,8 @@ import ( "strconv" "strings" + "github.com/jmoiron/sqlx" + "git.zxq.co/ripple/ocl" "git.zxq.co/ripple/rippleapi/common" ) @@ -36,7 +38,7 @@ LEFT JOIN users_stats ON users.id=users_stats.id WHERE ` + whereClause + ` AND users.privileges & 1 > 0 LIMIT 1` - return userPuts(md, md.DB.QueryRow(query, param)) + return userPuts(md, md.DB.QueryRowx(query, param)) } type userPutsUserData struct { @@ -44,14 +46,11 @@ type userPutsUserData struct { userData } -func userPuts(md common.MethodData, row *sql.Row) common.CodeMessager { +func userPuts(md common.MethodData, row *sqlx.Row) common.CodeMessager { var err error var user userPutsUserData - err = row.Scan( - &user.ID, &user.Username, &user.RegisteredOn, &user.Privileges, &user.LatestActivity, - &user.UsernameAKA, &user.Country, - ) + err = row.StructScan(&user.userData) switch { case err == sql.ErrNoRows: return common.SimpleResponse(404, "No such user was found!") @@ -95,7 +94,7 @@ func UserWhatsTheIDGET(md common.MethodData) common.CodeMessager { r whatIDResponse privileges int ) - err := md.DB.QueryRow("SELECT id, privileges FROM users WHERE username = ? LIMIT 1", md.C.Query("name")).Scan(&r.ID, &privileges) + err := md.DB.QueryRow("SELECT id, privileges FROM users WHERE username = ? LIMIT 1", md.Query("name")).Scan(&r.ID, &privileges) if err != nil || ((privileges&common.UserPrivilegePublic) == 0 && !md.User.Privileges.HasPrivilegeViewUserAdvanced()) { return common.SimpleResponse(404, "That user could not be found!") } @@ -243,17 +242,17 @@ func UserUserpageGET(md common.MethodData) common.CodeMessager { func whereClauseUser(md common.MethodData, tableName string) (*common.CodeMessager, string, interface{}) { switch { - case md.C.Query("id") == "self": + case md.Query("id") == "self": return nil, tableName + ".id = ?", md.ID() - case md.C.Query("id") != "": - id, err := strconv.Atoi(md.C.Query("id")) + case md.Query("id") != "": + id, err := strconv.Atoi(md.Query("id")) if err != nil { a := common.SimpleResponse(400, "please pass a valid user ID") return &a, "", nil } return nil, tableName + ".id = ?", id - case md.C.Query("name") != "": - return nil, tableName + ".username = ?", md.C.Query("name") + case md.Query("name") != "": + return nil, tableName + ".username = ?", md.Query("name") } a := common.SimpleResponse(400, "you need to pass either querystring parameters name or id") return &a, "", nil @@ -275,7 +274,7 @@ func UserLookupGET(md common.MethodData) common.CodeMessager { "%", "\\%", "_", "\\_", "\\", "\\\\", - ).Replace(md.C.Query("name")) + ).Replace(md.Query("name")) if name == "" { return common.SimpleResponse(400, "please provide an username to start searching") } diff --git a/app/v1/user_scores.go b/app/v1/user_scores.go index 251db52..a94bd73 100644 --- a/app/v1/user_scores.go +++ b/app/v1/user_scores.go @@ -3,7 +3,6 @@ package v1 import ( "fmt" "strconv" - "time" "git.zxq.co/ripple/rippleapi/common" ) @@ -46,7 +45,7 @@ func UserScoresBestGET(md common.MethodData) common.CodeMessager { } mc := genModeClause(md) // Do not print 0pp scores on std - if getMode(md.C.Query("mode")) == "std" { + if getMode(md.Query("mode")) == "std" { mc += " AND scores.pp > 0" } return scoresPuts(md, fmt.Sprintf( @@ -56,7 +55,7 @@ func UserScoresBestGET(md common.MethodData) common.CodeMessager { %s AND users.privileges & 1 > 0 ORDER BY scores.pp DESC, scores.score DESC %s`, - wc, mc, common.Paginate(md.C.Query("p"), md.C.Query("l"), 100), + wc, mc, common.Paginate(md.Query("p"), md.Query("l"), 100), ), param) } @@ -72,7 +71,7 @@ func UserScoresRecentGET(md common.MethodData) common.CodeMessager { %s AND users.privileges & 1 > 0 ORDER BY scores.time DESC %s`, - wc, genModeClause(md), common.Paginate(md.C.Query("p"), md.C.Query("l"), 100), + wc, genModeClause(md), common.Paginate(md.Query("p"), md.Query("l"), 100), ), param) } @@ -91,8 +90,8 @@ func getMode(m string) string { func genModeClause(md common.MethodData) string { var modeClause string - if md.C.Query("mode") != "" { - m, err := strconv.Atoi(md.C.Query("mode")) + if md.Query("mode") != "" { + m, err := strconv.Atoi(md.Query("mode")) if err == nil && m >= 0 && m <= 3 { modeClause = fmt.Sprintf("AND scores.play_mode = '%d'", m) } @@ -110,7 +109,6 @@ func scoresPuts(md common.MethodData, whereClause string, params ...interface{}) for rows.Next() { var ( us userScore - t string b beatmap ) err = rows.Scan( @@ -118,7 +116,7 @@ func scoresPuts(md common.MethodData, whereClause string, params ...interface{}) &us.MaxCombo, &us.FullCombo, &us.Mods, &us.Count300, &us.Count100, &us.Count50, &us.CountGeki, &us.CountKatu, &us.CountMiss, - &t, &us.PlayMode, &us.Accuracy, &us.PP, + &us.Time, &us.PlayMode, &us.Accuracy, &us.PP, &us.Completed, &b.BeatmapID, &b.BeatmapsetID, &b.BeatmapMD5, @@ -127,17 +125,11 @@ func scoresPuts(md common.MethodData, whereClause string, params ...interface{}) &b.MaxCombo, &b.HitLength, &b.Ranked, &b.RankedStatusFrozen, &b.LatestUpdate, ) - b.Difficulty = b.Diff2.STD if err != nil { md.Err(err) return Err500 } - // puck feppy - us.Time, err = time.Parse(common.OsuTimeFormat, t) - if _, ok := err.(*time.ParseError); !ok && err != nil { - md.Err(err) - return Err500 - } + b.Difficulty = b.Diff2.STD us.Beatmap = b scores = append(scores, us) } diff --git a/common/method_data.go b/common/method_data.go index d979985..50c4877 100644 --- a/common/method_data.go +++ b/common/method_data.go @@ -25,6 +25,11 @@ func (md MethodData) ID() int { return md.User.UserID } +// Query is shorthand for md.C.Query. +func (md MethodData) Query(q string) string { + return md.C.Query(q) +} + // RequestData is the body of a request. It is wrapped into this type // to implement the Unmarshal function, which is just a shorthand to // json.Unmarshal. diff --git a/common/osu_time_format.go b/common/osu_time_format.go index 006e509..56b44ea 100644 --- a/common/osu_time_format.go +++ b/common/osu_time_format.go @@ -1,4 +1,60 @@ package common +import ( + "errors" + "strconv" + "time" +) + // OsuTimeFormat is the time format for scores in the DB. Can be used with time.Parse etc. const OsuTimeFormat = "060102150405" + +// OsuTime is simply a time.Time, but can be used to convert an +// osu timestamp in the database into a native time.Time. +type OsuTime time.Time + +func (s *OsuTime) setTime(t string) error { + newTime, err := time.Parse(OsuTimeFormat, t) + if _, ok := err.(*time.ParseError); err != nil && !ok { + return err + } + if err == nil { + *s = OsuTime(newTime) + } + return nil +} + +// Scan decodes src into an OsuTime. +func (s *OsuTime) Scan(src interface{}) error { + if s == nil { + return errors.New("rippleapi/common: OsuTime is nil") + } + switch src := src.(type) { + case int64: + return s.setTime(strconv.FormatInt(src, 64)) + case float64: + return s.setTime(strconv.FormatInt(int64(src), 64)) + case string: + return s.setTime(src) + case []byte: + return s.setTime(string(src)) + case nil: + // Nothing, leave zero value on timestamp + default: + return errors.New("rippleapi/common: unhandleable type") + } + return nil +} + +// MarshalJSON -> time.Time.MarshalJSON +func (s OsuTime) MarshalJSON() ([]byte, error) { + return time.Time(s).MarshalJSON() +} + +// UnmarshalJSON -> time.Time.UnmarshalJSON +func (s *OsuTime) UnmarshalJSON(x []byte) error { + t := new(time.Time) + err := t.UnmarshalJSON(x) + *s = OsuTime(*t) + return err +} diff --git a/common/sort.go b/common/sort.go new file mode 100644 index 0000000..e7f8d35 --- /dev/null +++ b/common/sort.go @@ -0,0 +1,52 @@ +package common + +import "strings" + +// SortConfiguration is the configuration of Sort. +type SortConfiguration struct { + Allowed []string // Allowed parameters + Default string + DefaultSorting string // if empty, DESC + Table string +} + +// Sort allows the request to modify how the query is sorted. +func Sort(md MethodData, config SortConfiguration) string { + if config.DefaultSorting == "" { + config.DefaultSorting = "DESC" + } + if config.Table != "" { + config.Table += "." + } + var sortBy string + for _, s := range md.C.Request.URL.Query()["sort"] { + sortParts := strings.Split(strings.ToLower(s), ",") + if contains(config.Allowed, sortParts[0]) { + if sortBy != "" { + sortBy += ", " + } + sortBy += config.Table + sortParts[0] + " " + if len(sortParts) > 1 && contains([]string{"asc", "desc"}, sortParts[1]) { + sortBy += sortParts[1] + } else { + sortBy += config.DefaultSorting + } + } + } + if sortBy == "" { + sortBy = config.Default + } + if sortBy == "" { + return "" + } + return "ORDER BY " + sortBy +} + +func contains(a []string, s string) bool { + for _, el := range a { + if s == el { + return true + } + } + return false +}