From ab89bda81911431b3cd7821c378219443be15556 Mon Sep 17 00:00:00 2001 From: Morgan Bazalgette Date: Sat, 28 Jan 2017 15:06:12 +0100 Subject: [PATCH] Make Blog API use Medium --- app/start.go | 9 +- app/v1/blog.go | 218 +++++++++++++++++++++++++++++++++---------------- 2 files changed, 153 insertions(+), 74 deletions(-) diff --git a/app/start.go b/app/start.go index c0d3ff5..cd62f5a 100644 --- a/app/start.go +++ b/app/start.go @@ -3,10 +3,6 @@ package app import ( "fmt" - "zxq.co/ripple/rippleapi/app/internals" - "zxq.co/ripple/rippleapi/app/peppy" - "zxq.co/ripple/rippleapi/app/v1" - "zxq.co/ripple/rippleapi/common" "github.com/DataDog/datadog-go/statsd" "github.com/getsentry/raven-go" "github.com/gin-gonic/contrib/gzip" @@ -14,6 +10,10 @@ import ( "github.com/jmoiron/sqlx" "github.com/serenize/snaker" "gopkg.in/redis.v5" + "zxq.co/ripple/rippleapi/app/internals" + "zxq.co/ripple/rippleapi/app/peppy" + "zxq.co/ripple/rippleapi/app/v1" + "zxq.co/ripple/rippleapi/common" ) var ( @@ -115,7 +115,6 @@ func Start(conf common.Conf, dbO *sqlx.DB) *gin.Engine { gv1.GET("/users/self", Method(v1.UserSelfGET)) 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)) gv1.GET("/beatmaps/rank_requests/status", Method(v1.BeatmapRankRequestsStatusGET)) diff --git a/app/v1/blog.go b/app/v1/blog.go index 5c83ad5..471a3d4 100644 --- a/app/v1/blog.go +++ b/app/v1/blog.go @@ -1,17 +1,75 @@ package v1 import ( + "bytes" + "encoding/gob" + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "strings" "time" "zxq.co/ripple/rippleapi/common" ) +// This basically proxies requests from Medium's API and is used on Ripple's +// home page to display the latest blog posts. + +type mediumResp struct { + Success bool `json:"success"` + Payload struct { + Posts []mediumPost `json:"posts"` + } `json:"payload"` +} + +type mediumPost struct { + ID string `json:"id"` + Creator mediumUser `json:"creator"` + Title string `json:"title"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + Virtuals mediumPostVirtuals `json:"virtuals"` + ImportedURL string `json:"importedUrl"` + UniqueSlug string `json:"uniqueSlug"` +} + +type mediumUser struct { + UserID string `json:"userId"` + Name string `json:"name"` + Username string `json:"username"` +} + +type mediumPostVirtuals struct { + CreatedAtRelative string `json:"createdAtRelative"` + UpdatedAtRelative string `json:"updatedAtRelative"` + Snippet string `json:"snippet"` + WordCount int `json:"wordCount"` + ReadingTime float64 `json:"readingTime"` +} + +// there's gotta be a better way + type blogPost struct { - ID int `json:"id"` - Title string `json:"title"` - Slug string `json:"slug"` - Created time.Time `json:"created"` - Author userData `json:"author"` + ID string `json:"id"` + Creator blogUser `json:"creator"` + Title string `json:"title"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ImportedURL string `json:"imported_url"` + UniqueSlug string `json:"unique_slug"` + + CreatedAtRelative string `json:"created_at_relative"` + UpdatedAtRelative string `json:"updated_at_relative"` + Snippet string `json:"snippet"` + WordCount int `json:"word_count"` + ReadingTime float64 `json:"reading_time"` +} + +type blogUser struct { + UserID string `json:"user_id"` + Name string `json:"name"` + Username string `json:"username"` } type blogPostsResponse struct { @@ -19,80 +77,102 @@ type blogPostsResponse struct { Posts []blogPost `json:"posts"` } +// consts for the medium API +const ( + mediumAPIResponsePrefix = `])}while(1);` + mediumAPIAllPosts = `https://blog.ripple.moe/all?format=json` +) + +func init() { + gob.Register([]blogPost{}) +} + // BlogPostsGET retrieves the latest blog posts on the Ripple blog. func BlogPostsGET(md common.MethodData) common.CodeMessager { - var and string - var params []interface{} - if md.Query("id") != "" { - and = "b.id = ?" - params = append(params, md.Query("id")) + // check if posts are cached in redis + res := md.R.Get("api:blog_posts").Val() + if res != "" { + // decode values + posts := make([]blogPost, 0, 20) + err := gob.NewDecoder(strings.NewReader(res)).Decode(&posts) + if err != nil { + md.Err(err) + return Err500 + } + + // create response and return + var r blogPostsResponse + r.Code = 200 + r.Posts = posts + return r } - rows, err := md.DB.Query(` - SELECT - b.id, b.title, b.slug, b.created, - - u.id, u.username, s.username_aka, u.register_datetime, - u.privileges, u.latest_activity, s.country - FROM anchor_posts b - INNER JOIN users u ON b.author = u.id - INNER JOIN users_stats s ON b.author = s.id - WHERE status = "published" `+and+` - ORDER BY b.id DESC `+common.Paginate(md.Query("p"), md.Query("l"), 50), params...) + + // get data from medium api + resp, err := http.Get(mediumAPIAllPosts) if err != nil { md.Err(err) return Err500 } - var r blogPostsResponse - for rows.Next() { - var post blogPost - err := rows.Scan( - &post.ID, &post.Title, &post.Slug, &post.Created, - - &post.Author.ID, &post.Author.Username, &post.Author.UsernameAKA, &post.Author.RegisteredOn, - &post.Author.Privileges, &post.Author.LatestActivity, &post.Author.Country, - ) - if err != nil { - md.Err(err) - continue - } - r.Posts = append(r.Posts, post) - } - r.Code = 200 - - return r -} - -type blogPostContent struct { - common.ResponseBase - Content string `json:"content"` -} - -// BlogPostsContentGET retrieves the content of a specific blog post. -func BlogPostsContentGET(md common.MethodData) common.CodeMessager { - field := "markdown" - if md.HasQuery("html") { - field = "html" - } - var ( - by string - val string - ) - switch { - case md.Query("slug") != "": - by = "slug" - val = md.Query("slug") - case md.Query("id") != "": - by = "id" - val = md.Query("id") - default: - return ErrMissingField("id|slug") - } - var r blogPostContent - err := md.DB.QueryRow("SELECT "+field+" FROM anchor_posts WHERE "+by+" = ? AND status = 'published'", val).Scan(&r.Content) + // read body and trim the prefix + all, err := ioutil.ReadAll(resp.Body) if err != nil { - return common.SimpleResponse(404, "no blog post found") + md.Err(err) + return Err500 } + all = bytes.TrimPrefix(all, []byte(mediumAPIResponsePrefix)) + + // unmarshal into response struct + var mResp mediumResp + err = json.Unmarshal(all, &mResp) + if err != nil { + md.Err(err) + return Err500 + } + + if !mResp.Success { + md.Err(errors.New("medium api call is not successful")) + return Err500 + } + + // create posts slice and fill it up with converted posts from the medium + // API + posts := make([]blogPost, len(mResp.Payload.Posts)) + for idx, mp := range mResp.Payload.Posts { + var p blogPost + + // convert structs + p.ID = mp.ID + p.Title = mp.Title + p.CreatedAt = time.Unix(0, mp.CreatedAt*1000000) + p.UpdatedAt = time.Unix(0, mp.UpdatedAt*1000000) + p.ImportedURL = mp.ImportedURL + p.UniqueSlug = mp.UniqueSlug + + p.Creator.UserID = mp.Creator.UserID + p.Creator.Name = mp.Creator.Name + p.Creator.Username = mp.Creator.Username + + p.CreatedAtRelative = mp.Virtuals.CreatedAtRelative + p.UpdatedAtRelative = mp.Virtuals.UpdatedAtRelative + p.Snippet = mp.Virtuals.Snippet + p.WordCount = mp.Virtuals.WordCount + p.ReadingTime = mp.Virtuals.ReadingTime + + posts[idx] = p + } + + // save in redis + bb := new(bytes.Buffer) + err = gob.NewEncoder(bb).Encode(posts) + if err != nil { + md.Err(err) + return Err500 + } + md.R.Set("api:blog_posts", bb.Bytes(), time.Minute*5) + + var r blogPostsResponse r.Code = 200 + r.Posts = posts return r }