Skip to content

Commit

Permalink
feat: add leaderboard endpoint (#15)
Browse files Browse the repository at this point in the history
* refactor: move posthog query and generic reponse to lib directory

* feat: add leaderboard endpoint

* test: add test for leaderboard and fix references
  • Loading branch information
kieranroneill authored Jul 25, 2024
1 parent 2209099 commit decc31e
Show file tree
Hide file tree
Showing 21 changed files with 300 additions and 14 deletions.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
package queries
package utils

import (
"bytes"
"fmt"
"lib/constants"
"lib/utils"
"net/http"
"os"
"strings"
"time"
)

func PostHogQuery(query string, output any) error {
func SendPostHogQuery(query string, output any) error {
path := strings.Replace(constants.QueryPath, ":project_id", os.Getenv("POSTHOG_PROJECT_ID"), -1)
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", os.Getenv("POSTHOG_API_URL"), path), bytes.NewReader([]byte(query)))
if err != nil {
Expand All @@ -31,7 +30,7 @@ func PostHogQuery(query string, output any) error {
return err
}

err = utils.ParseResponseBody(response.Body, &output)
err = ParseResponseBody(response.Body, &output)
if err != nil {
return err
}
Expand Down
14 changes: 14 additions & 0 deletions packages/v1/leaderboard/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module leaderboard

go 1.20

require lib v0.0.0

require (
github.com/fatih/color v1.17.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.21.0 // indirect
)

replace lib v0.0.0 => ../../../lib
11 changes: 11 additions & 0 deletions packages/v1/leaderboard/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
3 changes: 3 additions & 0 deletions packages/v1/leaderboard/internal/constants/limits.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package constants

const ResultLimit = 1000
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package queries

import (
"fmt"
_constants "leaderboard/internal/constants"
_types "leaderboard/internal/types"
"lib/types"
"lib/utils"
)

// FetchLeaderboardResults Fetches a page of leaderboard results. The page is zero-indexed.
func FetchLeaderboardResults(page int, logger *utils.Logger) ([]_types.LeaderboardResult, error) {
var response types.PostHogQueryResponse
var results []_types.LeaderboardResult

query := fmt.Sprintf(`{"query":{"kind":"HogQLQuery","query":"select person.pdi.distinct_id as \"account\", count(distinct dateTrunc('day', events.timestamp) || events.event) as \"total\" from events where events.properties.genesisHash = 'IXnoWtviVVJW5LGivNFc0Dq14V3kqaXuK2u5OQrdVZo=' group by \"account\" order by \"total\" desc limit %d offset %d * %d"}}`, _constants.ResultLimit, page, _constants.ResultLimit)

if err := utils.SendPostHogQuery(query, &response); err != nil {
logger.Error(err)

return nil, err
}

for _, value := range response.Results {
results = append(results, _types.LeaderboardResult{
Account: value[0].(string),
Total: int(value[1].(float64)),
})
}

// if the results are nil, return an empty array
if results == nil {
return []_types.LeaderboardResult{}, nil
}

return results, nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package queries

import (
"fmt"
"lib/types"
"lib/utils"
)

// FetchTotalAccountsCompleted Fetches the total number of accounts that have completed at least one event.
func FetchTotalAccountsCompleted(logger *utils.Logger) (int, error) {
var response types.PostHogQueryResponse

query := fmt.Sprintf(`{"query":{"kind":"HogQLQuery","query":"select count(distinct person.pdi.distinct_id) as \"total\" from events where events.properties.genesisHash = 'IXnoWtviVVJW5LGivNFc0Dq14V3kqaXuK2u5OQrdVZo='"}}`)
if err := utils.SendPostHogQuery(query, &response); err != nil {
logger.Error(err)

return 0, err
}

return int(response.Results[0][0].(float64)), nil
}
10 changes: 10 additions & 0 deletions packages/v1/leaderboard/internal/types/leaderboardresult.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package types

// LeaderboardResult
// @Description The account and the total daily quests the account has completed.
type LeaderboardResult struct {
// The base 32 encoded address of the account
Account string `json:"account" example:"TESTK4BURRDGVVHAX2FBY7CPRC2RTTVRRN4C2TVDCHRCXNTFGL3TVSDROE"`
// The total number of daily quests completed
Total int `json:"total" example:"22"`
}
12 changes: 12 additions & 0 deletions packages/v1/leaderboard/internal/types/paginationmetadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package types

type PaginationMetadata struct {
// The current page of results
Page int `json:"page" example:"0"`
// The total amount of pages
PageCount int `json:"pageCount" example:"3"`
// The amount of results per page
PageSize int `json:"pageSize" example:"1000"`
// The total number of results
Total int `json:"total" example:"1024"`
}
8 changes: 8 additions & 0 deletions packages/v1/leaderboard/internal/types/request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package types

import "lib/types"

type Request struct {
Http types.Http `json:"http,omitempty"`
Page string `json:"page,omitempty"`
}
7 changes: 7 additions & 0 deletions packages/v1/leaderboard/internal/types/response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package types

type Response struct {
Body ResponseBody `json:"body,omitempty"`
Headers ResponseHeaders `json:"headers,omitempty"`
StatusCode int `json:"statusCode,omitempty"`
}
9 changes: 9 additions & 0 deletions packages/v1/leaderboard/internal/types/responsebody.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package types

type ResponseBody struct {
Error interface{} `json:"error,omitempty" swaggertype:"object"`
// The pagination metadata
Metadata PaginationMetadata `json:"metadata"`
// The leaderboard results; the completed quests for each account
Results []LeaderboardResult `json:"results"`
}
6 changes: 6 additions & 0 deletions packages/v1/leaderboard/internal/types/responseheaders.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package types

type ResponseHeaders struct {
CacheControl string `json:"Cache-Control"`
ContentType string `json:"Content-Type"`
}
95 changes: 95 additions & 0 deletions packages/v1/leaderboard/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package main

import (
"fmt"
_constants "leaderboard/internal/constants"
_queries "leaderboard/internal/queries"
_types "leaderboard/internal/types"
"lib/constants"
"lib/errors"
"lib/utils"
"math"
"net/http"
"strconv"
)

// Main godoc
// @Summary Quests Leaderboard
// @Description Gets the quests' leaderboard for all accounts.
// @Produce json
// @Param page query string false "the page of results, the first page starts at 0" Example(0)
// @Success 200 {object} _types.ResponseBody
// @Failure 405 "If it is not a GET request"
// @Failure 500
// @Header all {string} Cache-Control "public, max-age=3600"
// @Router /v1/leaderboard [get]
func Main(request _types.Request) *_types.Response {
var err error
var page = 0

logger := utils.NewLogger()
headers := _types.ResponseHeaders{
CacheControl: fmt.Sprintf("public, max-age=%d", constants.HourInSeconds),
ContentType: "application/json",
}

// only accept get requests
if request.Http.Method != http.MethodGet {
return &_types.Response{
Headers: headers,
StatusCode: http.StatusMethodNotAllowed,
}
}

if request.Page != "" {
if page, err = strconv.Atoi(request.Page); err != nil {
logger.Debug(fmt.Sprintf("failed to parse page \"%s\" to integer", request.Page))
}
}

logger.Debug("fetching total number of accounts that have completed at least one event")

total, err := _queries.FetchTotalAccountsCompleted(logger)
if err != nil {
return &_types.Response{
Body: _types.ResponseBody{
Error: errors.NewPostHogError("failed to fetch the total amount of accounts that have completed an event from posthog", err),
},
Headers: headers,
StatusCode: http.StatusInternalServerError,
}
}

logger.Debug(fmt.Sprintf("received the total amount of accounts that have completed an event: %d", total))

pageCount := int(math.Ceil(float64(total / _constants.ResultLimit))) // pages are zero-based

logger.Debug(fmt.Sprintf("fetching total daily event for each account from page %d of %d from posthog", page, pageCount))

results, err := _queries.FetchLeaderboardResults(page, logger)
if err != nil {
return &_types.Response{
Body: _types.ResponseBody{
Error: errors.NewPostHogError(fmt.Sprintf("failed to fetch the total number of completed events for page %d of %d from posthog", page, pageCount), err),
},
Headers: headers,
StatusCode: http.StatusInternalServerError,
}
}

logger.Debug(fmt.Sprintf("received total number of completed events for page %d of %d from posthog", page, pageCount))

return &_types.Response{
Body: _types.ResponseBody{
Metadata: _types.PaginationMetadata{
Page: page,
PageCount: pageCount,
PageSize: _constants.ResultLimit,
Total: total,
},
Results: results,
},
Headers: headers,
StatusCode: http.StatusOK,
}
}
39 changes: 39 additions & 0 deletions packages/v1/leaderboard/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package main

import (
_types "leaderboard/internal/types"
"lib/types"
"log"
"net/http"
"os"
"testing"
)

func TestMain(m *testing.M) {
err := os.Setenv("ENVIRONMENT", "test")
if err != nil {
log.Fatalf("failed to set \"ENVIRONMENT\" variable to \"test\"")
}

// run tests
code := m.Run()

os.Exit(code)
}

func TestIncorrectHTTPMethod(t *testing.T) {
// arrange
request := _types.Request{
Http: types.Http{
Method: http.MethodPost,
},
}

// act
response := Main(request)

// assert
if response.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("expected \"statusCode\" to be \"%d\", got %d", http.StatusMethodNotAllowed, response.StatusCode)
}
}
5 changes: 3 additions & 2 deletions packages/v1/quests/internal/queries/fetchdailyevents.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@ package queries

import (
"fmt"
"lib/types"
"lib/utils"
_types "quests/internal/types"
)

func FetchDailyEvents(account string, logger *utils.Logger) ([]_types.DailyEvent, error) {
var response _types.PostHogQueryResponse
var response types.PostHogQueryResponse
var result []_types.DailyEvent

query := fmt.Sprintf(`{"query":{"kind":"HogQLQuery","query":"select events.event as \"event-name\", count() > 0 as \"completed\" from events where person.pdi.distinct_id = '%s' and events.properties.genesisHash = 'IXnoWtviVVJW5LGivNFc0Dq14V3kqaXuK2u5OQrdVZo=' and events.timestamp >= today() group by \"event-name\" order by \"event-name\""}}`, account)
err := PostHogQuery(query, &response)
err := utils.SendPostHogQuery(query, &response)
if err != nil {
logger.Error(err)

Expand Down
6 changes: 3 additions & 3 deletions packages/v1/quests/internal/queries/fetcheventreferences.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
package queries

import (
"lib/types"
"lib/utils"
_types "quests/internal/types"
)

func FetchEventReferences(logger *utils.Logger) ([]string, error) {
var response _types.PostHogQueryResponse
var response types.PostHogQueryResponse
var result []string

err := PostHogQuery(`{"query":{"kind":"HogQLQuery","query":"select distinct events.event from events"}}`, &response)
err := utils.SendPostHogQuery(`{"query":{"kind":"HogQLQuery","query":"select distinct events.event from events"}}`, &response)
if err != nil {
logger.Error(err)

Expand Down
5 changes: 3 additions & 2 deletions packages/v1/quests/internal/queries/fetchtotaldailyevents.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@ package queries

import (
"fmt"
"lib/types"
"lib/utils"
_types "quests/internal/types"
)

func FetchTotalDailyEvents(account string, logger *utils.Logger) ([]_types.TotalDailyEvent, error) {
var response _types.PostHogQueryResponse
var response types.PostHogQueryResponse
var result []_types.TotalDailyEvent

query := fmt.Sprintf(`{"query":{"kind":"HogQLQuery","query":"select events.event as \"event-name\", count(distinct dateTrunc('day', events.timestamp)) AS \"total\" from events where person.pdi.distinct_id = '%s' and events.properties.genesisHash = 'IXnoWtviVVJW5LGivNFc0Dq14V3kqaXuK2u5OQrdVZo=' group by \"event-name\" order by \"event-name\""}}`, account)
err := PostHogQuery(query, &response)
err := utils.SendPostHogQuery(query, &response)
if err != nil {
logger.Error(err)

Expand Down
6 changes: 3 additions & 3 deletions packages/v1/quests/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"log"
"net/http"
"os"
internaltypes "quests/internal/types"
_types "quests/internal/types"
"testing"
)

Expand All @@ -27,7 +27,7 @@ func TestMain(m *testing.M) {

func TestIncorrectHTTPMethod(t *testing.T) {
// arrange
request := internaltypes.Request{
request := _types.Request{
Account: account,
Http: types.Http{
Method: http.MethodPost,
Expand All @@ -45,7 +45,7 @@ func TestIncorrectHTTPMethod(t *testing.T) {

func TestInvalidAccount(t *testing.T) {
// arrange
request := internaltypes.Request{
request := _types.Request{
Account: "not a valid account",
Http: types.Http{
Method: http.MethodGet,
Expand Down
Loading

0 comments on commit decc31e

Please sign in to comment.