From decc31e3738f1ef8e881b6ce5e427158aca322ec Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Thu, 25 Jul 2024 20:36:07 +0300 Subject: [PATCH] feat: add leaderboard endpoint (#15) * refactor: move posthog query and generic reponse to lib directory * feat: add leaderboard endpoint * test: add test for leaderboard and fix references --- .../types/posthogqueryresponse.go | 0 .../utils/sendposthogquery.go | 7 +- packages/v1/leaderboard/go.mod | 14 +++ packages/v1/leaderboard/go.sum | 11 +++ .../leaderboard/internal/constants/limits.go | 3 + .../queries/fetchleaderboardresults.go | 37 ++++++++ .../queries/fetchtotalaccountscompleted.go | 21 ++++ .../internal/types/leaderboardresult.go | 10 ++ .../internal/types/paginationmetadata.go | 12 +++ .../v1/leaderboard/internal/types/request.go | 8 ++ .../v1/leaderboard/internal/types/response.go | 7 ++ .../internal/types/responsebody.go | 9 ++ .../internal/types/responseheaders.go | 6 ++ packages/v1/leaderboard/main.go | 95 +++++++++++++++++++ packages/v1/leaderboard/main_test.go | 39 ++++++++ .../internal/queries/fetchdailyevents.go | 5 +- .../internal/queries/fetcheventreferences.go | 6 +- .../internal/queries/fetchtotaldailyevents.go | 5 +- packages/v1/quests/main_test.go | 6 +- packages/v1/versions/main.go | 1 + project.yml | 12 +++ 21 files changed, 300 insertions(+), 14 deletions(-) rename {packages/v1/quests/internal => lib}/types/posthogqueryresponse.go (100%) rename packages/v1/quests/internal/queries/posthogquery.go => lib/utils/sendposthogquery.go (83%) create mode 100644 packages/v1/leaderboard/go.mod create mode 100644 packages/v1/leaderboard/go.sum create mode 100644 packages/v1/leaderboard/internal/constants/limits.go create mode 100644 packages/v1/leaderboard/internal/queries/fetchleaderboardresults.go create mode 100644 packages/v1/leaderboard/internal/queries/fetchtotalaccountscompleted.go create mode 100644 packages/v1/leaderboard/internal/types/leaderboardresult.go create mode 100644 packages/v1/leaderboard/internal/types/paginationmetadata.go create mode 100644 packages/v1/leaderboard/internal/types/request.go create mode 100644 packages/v1/leaderboard/internal/types/response.go create mode 100644 packages/v1/leaderboard/internal/types/responsebody.go create mode 100644 packages/v1/leaderboard/internal/types/responseheaders.go create mode 100644 packages/v1/leaderboard/main.go create mode 100644 packages/v1/leaderboard/main_test.go diff --git a/packages/v1/quests/internal/types/posthogqueryresponse.go b/lib/types/posthogqueryresponse.go similarity index 100% rename from packages/v1/quests/internal/types/posthogqueryresponse.go rename to lib/types/posthogqueryresponse.go diff --git a/packages/v1/quests/internal/queries/posthogquery.go b/lib/utils/sendposthogquery.go similarity index 83% rename from packages/v1/quests/internal/queries/posthogquery.go rename to lib/utils/sendposthogquery.go index 8bf560a..e7036fb 100644 --- a/packages/v1/quests/internal/queries/posthogquery.go +++ b/lib/utils/sendposthogquery.go @@ -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 { @@ -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 } diff --git a/packages/v1/leaderboard/go.mod b/packages/v1/leaderboard/go.mod new file mode 100644 index 0000000..d4acc95 --- /dev/null +++ b/packages/v1/leaderboard/go.mod @@ -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 diff --git a/packages/v1/leaderboard/go.sum b/packages/v1/leaderboard/go.sum new file mode 100644 index 0000000..b303f8e --- /dev/null +++ b/packages/v1/leaderboard/go.sum @@ -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= diff --git a/packages/v1/leaderboard/internal/constants/limits.go b/packages/v1/leaderboard/internal/constants/limits.go new file mode 100644 index 0000000..16f8bb1 --- /dev/null +++ b/packages/v1/leaderboard/internal/constants/limits.go @@ -0,0 +1,3 @@ +package constants + +const ResultLimit = 1000 diff --git a/packages/v1/leaderboard/internal/queries/fetchleaderboardresults.go b/packages/v1/leaderboard/internal/queries/fetchleaderboardresults.go new file mode 100644 index 0000000..d55b027 --- /dev/null +++ b/packages/v1/leaderboard/internal/queries/fetchleaderboardresults.go @@ -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 +} diff --git a/packages/v1/leaderboard/internal/queries/fetchtotalaccountscompleted.go b/packages/v1/leaderboard/internal/queries/fetchtotalaccountscompleted.go new file mode 100644 index 0000000..f7c0245 --- /dev/null +++ b/packages/v1/leaderboard/internal/queries/fetchtotalaccountscompleted.go @@ -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 +} diff --git a/packages/v1/leaderboard/internal/types/leaderboardresult.go b/packages/v1/leaderboard/internal/types/leaderboardresult.go new file mode 100644 index 0000000..72b47f7 --- /dev/null +++ b/packages/v1/leaderboard/internal/types/leaderboardresult.go @@ -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"` +} diff --git a/packages/v1/leaderboard/internal/types/paginationmetadata.go b/packages/v1/leaderboard/internal/types/paginationmetadata.go new file mode 100644 index 0000000..0088e14 --- /dev/null +++ b/packages/v1/leaderboard/internal/types/paginationmetadata.go @@ -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"` +} diff --git a/packages/v1/leaderboard/internal/types/request.go b/packages/v1/leaderboard/internal/types/request.go new file mode 100644 index 0000000..5ec3e53 --- /dev/null +++ b/packages/v1/leaderboard/internal/types/request.go @@ -0,0 +1,8 @@ +package types + +import "lib/types" + +type Request struct { + Http types.Http `json:"http,omitempty"` + Page string `json:"page,omitempty"` +} diff --git a/packages/v1/leaderboard/internal/types/response.go b/packages/v1/leaderboard/internal/types/response.go new file mode 100644 index 0000000..dfde350 --- /dev/null +++ b/packages/v1/leaderboard/internal/types/response.go @@ -0,0 +1,7 @@ +package types + +type Response struct { + Body ResponseBody `json:"body,omitempty"` + Headers ResponseHeaders `json:"headers,omitempty"` + StatusCode int `json:"statusCode,omitempty"` +} diff --git a/packages/v1/leaderboard/internal/types/responsebody.go b/packages/v1/leaderboard/internal/types/responsebody.go new file mode 100644 index 0000000..124460d --- /dev/null +++ b/packages/v1/leaderboard/internal/types/responsebody.go @@ -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"` +} diff --git a/packages/v1/leaderboard/internal/types/responseheaders.go b/packages/v1/leaderboard/internal/types/responseheaders.go new file mode 100644 index 0000000..ad32c28 --- /dev/null +++ b/packages/v1/leaderboard/internal/types/responseheaders.go @@ -0,0 +1,6 @@ +package types + +type ResponseHeaders struct { + CacheControl string `json:"Cache-Control"` + ContentType string `json:"Content-Type"` +} diff --git a/packages/v1/leaderboard/main.go b/packages/v1/leaderboard/main.go new file mode 100644 index 0000000..98465c4 --- /dev/null +++ b/packages/v1/leaderboard/main.go @@ -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, + } +} diff --git a/packages/v1/leaderboard/main_test.go b/packages/v1/leaderboard/main_test.go new file mode 100644 index 0000000..92e40be --- /dev/null +++ b/packages/v1/leaderboard/main_test.go @@ -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) + } +} diff --git a/packages/v1/quests/internal/queries/fetchdailyevents.go b/packages/v1/quests/internal/queries/fetchdailyevents.go index 6404bee..92e3ebe 100644 --- a/packages/v1/quests/internal/queries/fetchdailyevents.go +++ b/packages/v1/quests/internal/queries/fetchdailyevents.go @@ -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) diff --git a/packages/v1/quests/internal/queries/fetcheventreferences.go b/packages/v1/quests/internal/queries/fetcheventreferences.go index c5d040a..d1cf664 100644 --- a/packages/v1/quests/internal/queries/fetcheventreferences.go +++ b/packages/v1/quests/internal/queries/fetcheventreferences.go @@ -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) diff --git a/packages/v1/quests/internal/queries/fetchtotaldailyevents.go b/packages/v1/quests/internal/queries/fetchtotaldailyevents.go index ef7803f..09626f4 100644 --- a/packages/v1/quests/internal/queries/fetchtotaldailyevents.go +++ b/packages/v1/quests/internal/queries/fetchtotaldailyevents.go @@ -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) diff --git a/packages/v1/quests/main_test.go b/packages/v1/quests/main_test.go index b119644..e308472 100644 --- a/packages/v1/quests/main_test.go +++ b/packages/v1/quests/main_test.go @@ -7,7 +7,7 @@ import ( "log" "net/http" "os" - internaltypes "quests/internal/types" + _types "quests/internal/types" "testing" ) @@ -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, @@ -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, diff --git a/packages/v1/versions/main.go b/packages/v1/versions/main.go index 8030761..1b5433e 100644 --- a/packages/v1/versions/main.go +++ b/packages/v1/versions/main.go @@ -28,6 +28,7 @@ func Main() *_types.Response { }, Headers: _types.ResponseHeaders{ CacheControl: fmt.Sprintf("public, max-age=%d", constants.TwentyFourHoursInSeconds), + ContentType: "application/json", }, StatusCode: http.StatusOK, } diff --git a/project.yml b/project.yml index 0bb405b..6e4ef4b 100644 --- a/project.yml +++ b/project.yml @@ -4,6 +4,18 @@ packages: - name: v1 shared: false functions: + - name: leaderboard + binary: true + main: "main" + runtime: go:1.20 + web: true + environment: + POSTHOG_API_URL: "${POSTHOG_API_URL}" + POSTHOG_API_KEY: "${POSTHOG_API_KEY}" + POSTHOG_PROJECT_ID: "${POSTHOG_PROJECT_ID}" + limits: + timeout: 30000 # 30 seconds in milliseconds + - name: quests binary: true main: "main"