Implement GET scores in official ripple api

This commit is contained in:
Howl 2016-08-15 19:59:46 +02:00
parent 346f26177c
commit a6ca8de13e
16 changed files with 286 additions and 53 deletions

View File

@ -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))

View File

@ -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")
}

View File

@ -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)
}

View File

@ -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")
}

View File

@ -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

View File

@ -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, ", ") + ".",
}
}

View File

@ -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 {

View File

@ -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)

View File

@ -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))
}

115
app/v1/score.go Normal file
View File

@ -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
}

View File

@ -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)

View File

@ -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")
}

View File

@ -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)
}

View File

@ -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.

View File

@ -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
}

52
common/sort.go Normal file
View File

@ -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
}