From 69ef083abe7c6532919ddb7fed09ca41f65a92bc Mon Sep 17 00:00:00 2001 From: Shekar Siri Date: Fri, 13 Dec 2024 14:08:03 +0100 Subject: [PATCH] feat(pa): cards endpoints (#2871) * feat(analytics): dashboards * feat(analytics): cards api endpoints * feat(analytics): validator dependency --- backend/go.mod | 5 + backend/go.sum | 10 + backend/pkg/analytics/api/card-handlers.go | 268 ++++++++++++++++++ backend/pkg/analytics/api/card.go | 92 ++++++ .../pkg/analytics/api/dashboard-handlers.go | 18 +- backend/pkg/analytics/api/handlers.go | 9 +- backend/pkg/analytics/api/model.go | 2 +- 7 files changed, 393 insertions(+), 11 deletions(-) create mode 100644 backend/pkg/analytics/api/card-handlers.go create mode 100644 backend/pkg/analytics/api/card.go diff --git a/backend/go.mod b/backend/go.mod index c3123f334..c14121a16 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -55,8 +55,12 @@ require ( github.com/distribution/reference v0.6.0 // indirect github.com/elastic/elastic-transport-go/v8 v8.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.23.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -70,6 +74,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle v1.3.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/paulmach/orb v0.7.1 // indirect github.com/pierrec/lz4/v4 v4.1.15 // indirect diff --git a/backend/go.sum b/backend/go.sum index c088a9a3a..1a73eb0f8 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -171,6 +171,8 @@ github.com/fsnotify/fsevents v0.1.1/go.mod h1:+d+hS27T6k5J8CRaPLKFgwKYcpS7GwW3Ul github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fvbommel/sortorder v1.0.2 h1:mV4o8B2hKboCdkJm+a7uX/SIpZob4JzUpc5GGnM45eo= github.com/fvbommel/sortorder v1.0.2/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/getsentry/sentry-go v0.29.0 h1:YtWluuCFg9OfcqnaujpY918N/AhCCwarIDWOYSBAjCA= github.com/getsentry/sentry-go v0.29.0/go.mod h1:jhPesDAL0Q0W2+2YEuVOvdWmVtdsr1+jtBrlDEVWwLY= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -192,6 +194,12 @@ github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZ github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= github.com/go-openapi/swag v0.19.14 h1:gm3vOOXfiuw5i9p5N9xJvfjvuofpyvLA9Wr6QfK5Fng= github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= @@ -364,6 +372,8 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= diff --git a/backend/pkg/analytics/api/card-handlers.go b/backend/pkg/analytics/api/card-handlers.go new file mode 100644 index 000000000..41079c330 --- /dev/null +++ b/backend/pkg/analytics/api/card-handlers.go @@ -0,0 +1,268 @@ +package models + +import ( + "encoding/json" + "fmt" + "github.com/gorilla/mux" + "net/http" + "openreplay/backend/pkg/server/api" + "openreplay/backend/pkg/server/user" + "strconv" + "time" + + "github.com/go-playground/validator/v10" +) + +// getCardId returns the ID from the request +func getCardId(r *http.Request) (int64, error) { + vars := mux.Vars(r) + idStr := vars["id"] + if idStr == "" { + return 0, fmt.Errorf("invalid Card ID") + } + + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid Card ID") + } + + return id, nil +} + +func (e *handlersImpl) createCard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + bodyBytes, err := api.ReadBody(e.log, w, r, e.jsonSizeLimit) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize) + return + } + bodySize = len(bodyBytes) + + req := &CardCreateRequest{} + if err := json.Unmarshal(bodyBytes, req); err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + validate := validator.New() + err = validate.Struct(req) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + // TODO save card to DB + + resp := &CardGetResponse{ + Card: Card{ + CardID: 1, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + DeletedAt: nil, + EditedAt: nil, + ProjectID: 1, + UserID: 1, + CardBase: CardBase{ + Name: req.Name, + IsPublic: req.IsPublic, + Thumbnail: req.Thumbnail, + MetricType: req.MetricType, + MetricOf: req.MetricOf, + Series: req.Series, + }, + }, + } + + currentUser := r.Context().Value("userData").(*user.User) + e.log.Info(r.Context(), "User ID: ", currentUser.ID) + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} + +// getCard +func (e *handlersImpl) getCard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + id, err := getCardId(r) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + thumbnail := "https://example.com/image.png" + + // TODO get card from DB + + resp := &CardGetResponse{ + Card: Card{ + CardID: id, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + DeletedAt: nil, + EditedAt: nil, + ProjectID: 1, + UserID: 1, + CardBase: CardBase{ + Name: "My Card", + IsPublic: true, + Thumbnail: &thumbnail, + MetricType: "timeseries", + MetricOf: "session_count", + }, + }, + } + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} + +// get cards paginated +func (e *handlersImpl) getCards(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + // TODO get cards from DB + thumbnail := "https://example.com/image.png" + + resp := &GetCardsResponse{ + Cards: []Card{ + { + CardID: 1, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + DeletedAt: nil, + EditedAt: nil, + ProjectID: 1, + UserID: 1, + CardBase: CardBase{ + Name: "My Card", + IsPublic: true, + Thumbnail: &thumbnail, + MetricType: "timeseries", + MetricOf: "session_count", + }, + }, + }, + Total: 10, + } + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} + +func (e *handlersImpl) updateCard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + id, err := getCardId(r) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + bodyBytes, err := api.ReadBody(e.log, w, r, e.jsonSizeLimit) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize) + return + } + bodySize = len(bodyBytes) + + req := &CardUpdateRequest{} + if err := json.Unmarshal(bodyBytes, req); err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + validate := validator.New() + err = validate.Struct(req) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + // TODO update card in DB + + resp := &CardGetResponse{ + Card: Card{ + CardID: id, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + DeletedAt: nil, + EditedAt: nil, + ProjectID: 1, + UserID: 1, + CardBase: CardBase{ + Name: req.Name, + IsPublic: req.IsPublic, + Thumbnail: req.Thumbnail, + MetricType: req.MetricType, + MetricOf: req.MetricOf, + }, + }, + } + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} + +func (e *handlersImpl) deleteCard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + _, err := getCardId(r) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + // TODO delete card from DB + + e.responser.ResponseWithJSON(e.log, r.Context(), w, nil, startTime, r.URL.Path, bodySize) +} + +func (e *handlersImpl) getCardChartData(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + bodyBytes, err := api.ReadBody(e.log, w, r, e.jsonSizeLimit) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize) + return + } + bodySize = len(bodyBytes) + + req := &GetCardChartDataRequest{} + if err := json.Unmarshal(bodyBytes, req); err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + validate := validator.New() + err = validate.Struct(req) + + // TODO get card chart data from ClickHouse + jsonInput := ` + { + "data": [ + { + "timestamp": 1733934939000, + "Series A": 100, + "Series B": 200 + }, + { + "timestamp": 1733935939000, + "Series A": 150, + "Series B": 250 + } + ] + }` + + var resp GetCardChartDataResponse + err = json.Unmarshal([]byte(jsonInput), &resp) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize) + return + } + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} diff --git a/backend/pkg/analytics/api/card.go b/backend/pkg/analytics/api/card.go new file mode 100644 index 000000000..271d8c080 --- /dev/null +++ b/backend/pkg/analytics/api/card.go @@ -0,0 +1,92 @@ +package models + +import ( + "time" +) + +// CardBase Common fields for the Card entity +type CardBase struct { + Name string `json:"name" validate:"required"` + IsPublic bool `json:"isPublic" validate:"omitempty"` + DefaultConfig map[string]any `json:"defaultConfig"` + Thumbnail *string `json:"thumbnail" validate:"omitempty,url"` + MetricType string `json:"metricType" validate:"required,oneof=timeseries table funnel"` + MetricOf string `json:"metricOf" validate:"required,oneof=session_count user_count"` + MetricFormat string `json:"metricFormat" validate:"required,oneof=default percentage"` + ViewType string `json:"viewType" validate:"required,oneof=line_chart table_view"` + MetricValue []string `json:"metricValue" validate:"omitempty"` + SessionID *int64 `json:"sessionId" validate:"omitempty"` + Series []CardSeries `json:"series" validate:"required,dive"` +} + +// Card Fields specific to database operations +type Card struct { + CardBase + ProjectID int64 `json:"projectId" validate:"required"` + UserID int64 `json:"userId" validate:"required"` + CardID int64 `json:"cardId"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + EditedAt *time.Time `json:"edited_at,omitempty"` +} + +type CardSeries struct { + SeriesID int64 `json:"seriesId" validate:"omitempty"` + MetricID int64 `json:"metricId" validate:"omitempty"` + Name string `json:"name" validate:"required"` + CreatedAt time.Time `json:"createdAt" validate:"omitempty"` + DeletedAt *time.Time `json:"deletedAt" validate:"omitempty"` + Index int64 `json:"index" validate:"required"` + Filter SeriesFilter `json:"filter"` +} + +type SeriesFilter struct { + EventOrder string `json:"eventOrder" validate:"required,oneof=then or and"` + Filters []FilterItem `json:"filters"` +} + +type FilterItem struct { + Type string `json:"type" validate:"required"` + Operator string `json:"operator" validate:"required"` + Source string `json:"source" validate:"required"` + SourceOperator string `json:"sourceOperator" validate:"required"` + Value []string `json:"value" validate:"required,dive,required"` + IsEvent bool `json:"isEvent"` +} + +// CardCreateRequest Fields required for creating a card (from the frontend) +type CardCreateRequest struct { + CardBase +} + +type CardGetResponse struct { + Card +} + +type CardUpdateRequest struct { + CardBase +} + +type GetCardsResponse struct { + Cards []Card `json:"cards"` + Total int64 `json:"total"` +} + +type DataPoint struct { + Timestamp int64 `json:"timestamp"` + Series map[string]int64 `json:"series"` +} + +type GetCardChartDataRequest struct { + ProjectID int64 `json:"projectId" validate:"required"` + MetricType string `json:"metricType" validate:"required,oneof=timeseries table funnel"` + MetricOf string `json:"metricOf" validate:"required,oneof=session_count user_count"` + MetricFormat string `json:"metricFormat" validate:"required,oneof=default percentage"` + SessionID int64 `json:"sessionId" validate:"required"` + Series []CardSeries `json:"series"` +} + +type GetCardChartDataResponse struct { + Data []DataPoint `json:"data"` +} diff --git a/backend/pkg/analytics/api/dashboard-handlers.go b/backend/pkg/analytics/api/dashboard-handlers.go index ca8e22ba2..777180847 100644 --- a/backend/pkg/analytics/api/dashboard-handlers.go +++ b/backend/pkg/analytics/api/dashboard-handlers.go @@ -1,4 +1,4 @@ -package api +package models import ( "encoding/json" @@ -11,7 +11,7 @@ import ( "time" ) -func getId(r *http.Request) (int, error) { +func getDashboardId(r *http.Request) (int, error) { vars := mux.Vars(r) idStr := vars["id"] if idStr == "" { @@ -64,7 +64,7 @@ func (e *handlersImpl) getDashboards(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 - //id, err := getId(r) + //id, err := getDashboardId(r) //if err != nil { // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) // return @@ -90,7 +90,7 @@ func (e *handlersImpl) getDashboard(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 - id, err := getId(r) + id, err := getDashboardId(r) if err != nil { e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) return @@ -113,7 +113,7 @@ func (e *handlersImpl) updateDashboard(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 - //id, err := getId(r) + //id, err := getDashboardId(r) //if err != nil { // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) // return @@ -149,7 +149,7 @@ func (e *handlersImpl) deleteDashboard(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 - //id, err := getId(r) + //id, err := getDashboardId(r) //if err != nil { // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) // return @@ -163,7 +163,7 @@ func (e *handlersImpl) pinDashboard(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 - //id, err := getId(r) + //id, err := getDashboardId(r) //if err != nil { // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) // return @@ -179,7 +179,7 @@ func (e *handlersImpl) addCardToDashboard(w http.ResponseWriter, r *http.Request startTime := time.Now() bodySize := 0 - //id, err := getId(r) + //id, err := getDashboardId(r) //if err != nil { // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) // return @@ -195,7 +195,7 @@ func (e *handlersImpl) removeCardFromDashboard(w http.ResponseWriter, r *http.Re startTime := time.Now() bodySize := 0 - //id, err := getId(r) + //id, err := getDashboardId(r) //if err != nil { // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) // return diff --git a/backend/pkg/analytics/api/handlers.go b/backend/pkg/analytics/api/handlers.go index 05ee6dbfc..e3ad6ac6d 100644 --- a/backend/pkg/analytics/api/handlers.go +++ b/backend/pkg/analytics/api/handlers.go @@ -1,4 +1,4 @@ -package api +package models import ( config "openreplay/backend/internal/config/analytics" @@ -25,6 +25,13 @@ func (e *handlersImpl) GetAll() []*api.Description { {"/v1/analytics/{projectId}/dashboards/{id}", e.getDashboard, "GET"}, {"/v1/analytics/{projectId}/dashboards/{id}", e.updateDashboard, "PUT"}, {"/v1/analytics/{projectId}/dashboards/{id}", e.deleteDashboard, "DELETE"}, + {"/v1/analytics/{projectId}/cards", e.createCard, "POST"}, + {"/v1/analytics/{projectId}/cards", e.getCards, "GET"}, + {"/v1/analytics/{projectId}/cards/{id}", e.getCard, "GET"}, + {"/v1/analytics/{projectId}/cards/{id}", e.updateCard, "PUT"}, + {"/v1/analytics/{projectId}/cards/{id}", e.deleteCard, "DELETE"}, + {"/v1/analytics/{projectId}/cards/{id}/chart", e.getCardChartData, "POST"}, + {"/v1/analytics/{projectId}/cards/{id}/try", e.getCardChartData, "POST"}, } } diff --git a/backend/pkg/analytics/api/model.go b/backend/pkg/analytics/api/model.go index 3342a4b81..a5c231159 100644 --- a/backend/pkg/analytics/api/model.go +++ b/backend/pkg/analytics/api/model.go @@ -1,4 +1,4 @@ -package api +package models type Dashboard struct { DashboardID int `json:"dashboard_id"`