data layer
This commit is contained in:
0
data-layer/api/internal/handler/.gitkeep
Normal file
0
data-layer/api/internal/handler/.gitkeep
Normal file
132
data-layer/api/internal/handler/analytics_handler.go
Normal file
132
data-layer/api/internal/handler/analytics_handler.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/middleware"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/repo"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/service"
|
||||
)
|
||||
|
||||
type AnalyticsHandler struct {
|
||||
svc *service.QueryService
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewAnalyticsHandler(svc *service.QueryService, log *zap.Logger) *AnalyticsHandler {
|
||||
return &AnalyticsHandler{svc: svc, log: log}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Funnel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type funnelRequest struct {
|
||||
Steps []string `json:"steps" validate:"required,min=2,max=10,dive,min=1"`
|
||||
From *time.Time `json:"from" validate:"required"`
|
||||
To *time.Time `json:"to" validate:"required,gtfield=From"`
|
||||
WindowSeconds uint32 `json:"window_seconds" validate:"required,min=1,max=2592000"` // up to 30d
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) Funnel(w http.ResponseWriter, r *http.Request) {
|
||||
var req funnelRequest
|
||||
if err := decodeAndValidate(r, &req); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
ws := middleware.WorkspaceFromCtx(r.Context())
|
||||
res, err := h.svc.Funnel(r.Context(), repo.FunnelQuery{
|
||||
WorkspaceID: ws,
|
||||
Steps: req.Steps,
|
||||
From: *req.From,
|
||||
To: *req.To,
|
||||
WindowSeconds: req.WindowSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
h.log.Error("funnel", zap.String("workspace_id", ws), zap.Error(err))
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retention
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type retentionRequest struct {
|
||||
InitialEvent string `json:"initial_event" validate:"required,min=1"`
|
||||
ReturnEvent string `json:"return_event" validate:"required,min=1"`
|
||||
From *time.Time `json:"from" validate:"required"`
|
||||
To *time.Time `json:"to" validate:"required,gtfield=From"`
|
||||
Periods int `json:"periods" validate:"omitempty,min=1,max=90"`
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) Retention(w http.ResponseWriter, r *http.Request) {
|
||||
var req retentionRequest
|
||||
if err := decodeAndValidate(r, &req); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
ws := middleware.WorkspaceFromCtx(r.Context())
|
||||
res, err := h.svc.Retention(r.Context(), repo.RetentionQuery{
|
||||
WorkspaceID: ws,
|
||||
InitialEvent: req.InitialEvent,
|
||||
ReturnEvent: req.ReturnEvent,
|
||||
From: *req.From,
|
||||
To: *req.To,
|
||||
Periods: req.Periods,
|
||||
})
|
||||
if err != nil {
|
||||
h.log.Error("retention", zap.String("workspace_id", ws), zap.Error(err))
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type sessionRequest struct {
|
||||
From *time.Time `json:"from" validate:"required"`
|
||||
To *time.Time `json:"to" validate:"required,gtfield=From"`
|
||||
TimeoutSeconds uint32 `json:"timeout_seconds" validate:"omitempty,min=60,max=86400"`
|
||||
UserID string `json:"user_id"`
|
||||
Limit int `json:"limit" validate:"omitempty,min=1,max=1000"`
|
||||
Offset int `json:"offset" validate:"omitempty,min=0"`
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) Session(w http.ResponseWriter, r *http.Request) {
|
||||
var req sessionRequest
|
||||
if err := decodeAndValidate(r, &req); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
if req.TimeoutSeconds == 0 {
|
||||
req.TimeoutSeconds = 30 * 60
|
||||
}
|
||||
if req.Limit == 0 {
|
||||
req.Limit = 100
|
||||
}
|
||||
ws := middleware.WorkspaceFromCtx(r.Context())
|
||||
res, err := h.svc.Sessions(r.Context(), repo.SessionQuery{
|
||||
WorkspaceID: ws,
|
||||
UserID: req.UserID,
|
||||
From: *req.From,
|
||||
To: *req.To,
|
||||
TimeoutSeconds: req.TimeoutSeconds,
|
||||
Limit: req.Limit,
|
||||
Offset: req.Offset,
|
||||
})
|
||||
if err != nil {
|
||||
h.log.Error("session", zap.String("workspace_id", ws), zap.Error(err))
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
36
data-layer/api/internal/handler/decode.go
Normal file
36
data-layer/api/internal/handler/decode.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/apperr"
|
||||
)
|
||||
|
||||
var validate = validator.New(validator.WithRequiredStructEnabled())
|
||||
|
||||
// decodeAndValidate reads JSON into `dst`, then runs validator tags. Returns
|
||||
// a wrapped AppError so handlers can pass it straight to writeError.
|
||||
func decodeAndValidate(r *http.Request, dst any) error {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return apperr.BadRequest("request body is empty", "", err)
|
||||
}
|
||||
return apperr.BadRequest("invalid JSON: "+err.Error(), "", err)
|
||||
}
|
||||
if err := validate.Struct(dst); err != nil {
|
||||
var verrs validator.ValidationErrors
|
||||
if errors.As(err, &verrs) && len(verrs) > 0 {
|
||||
ve := verrs[0]
|
||||
return apperr.BadRequest("validation failed on "+ve.Field()+": "+ve.Tag(), ve.Field(), err)
|
||||
}
|
||||
return apperr.BadRequest("validation failed: "+err.Error(), "", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
74
data-layer/api/internal/handler/event_handler.go
Normal file
74
data-layer/api/internal/handler/event_handler.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/middleware"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/model"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/service"
|
||||
)
|
||||
|
||||
type EventHandler struct {
|
||||
svc *service.QueryService
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewEventHandler(svc *service.QueryService, log *zap.Logger) *EventHandler {
|
||||
return &EventHandler{svc: svc, log: log}
|
||||
}
|
||||
|
||||
type queryEventsRequest struct {
|
||||
Table string `json:"table" validate:"required,oneof=events_track events_identify events_page events_group"`
|
||||
From *time.Time `json:"from" validate:"required"`
|
||||
To *time.Time `json:"to" validate:"required,gtfield=From"`
|
||||
UserID string `json:"user_id"`
|
||||
AnonymousID string `json:"anonymous_id"`
|
||||
EventName string `json:"event"`
|
||||
Limit int `json:"limit" validate:"omitempty,min=1,max=1000"`
|
||||
Offset int `json:"offset" validate:"omitempty,min=0"`
|
||||
}
|
||||
|
||||
// QueryEvents handles POST /query/events.
|
||||
func (h *EventHandler) QueryEvents(w http.ResponseWriter, r *http.Request) {
|
||||
var req queryEventsRequest
|
||||
if err := decodeAndValidate(r, &req); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
if req.Limit == 0 {
|
||||
req.Limit = 100
|
||||
}
|
||||
|
||||
ws := middleware.WorkspaceFromCtx(r.Context())
|
||||
res, err := h.svc.Events(r.Context(), model.EventQuery{
|
||||
WorkspaceID: ws,
|
||||
Table: model.EventTable(req.Table),
|
||||
From: *req.From,
|
||||
To: *req.To,
|
||||
UserID: req.UserID,
|
||||
AnonymousID: req.AnonymousID,
|
||||
EventName: req.EventName,
|
||||
Limit: req.Limit,
|
||||
Offset: req.Offset,
|
||||
})
|
||||
if err != nil {
|
||||
h.log.Error("query events",
|
||||
zap.String("workspace_id", ws),
|
||||
zap.String("table", req.Table),
|
||||
zap.Error(err))
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// Health / Ready -- shared between all handlers but parked here for now.
|
||||
func (h *EventHandler) Health(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
func (h *EventHandler) Ready(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
}
|
||||
85
data-layer/api/internal/handler/profile_handler.go
Normal file
85
data-layer/api/internal/handler/profile_handler.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/apperr"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/middleware"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/service"
|
||||
)
|
||||
|
||||
type ProfileHandler struct {
|
||||
svc *service.ProfileService
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewProfileHandler(svc *service.ProfileService, log *zap.Logger) *ProfileHandler {
|
||||
return &ProfileHandler{svc: svc, log: log}
|
||||
}
|
||||
|
||||
// Get handles GET /profiles/:id.
|
||||
func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseProfileID(r)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
ws := middleware.WorkspaceFromCtx(r.Context())
|
||||
p, err := h.svc.Get(r.Context(), ws, id)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
// Timeline handles GET /profiles/:id/events.
|
||||
func (h *ProfileHandler) Timeline(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseProfileID(r)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
limit, offset := parsePagination(r, 100, 1000)
|
||||
|
||||
ws := middleware.WorkspaceFromCtx(r.Context())
|
||||
res, err := h.svc.Timeline(r.Context(), ws, id, limit, offset)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
func parseProfileID(r *http.Request) (string, error) {
|
||||
raw := chi.URLParam(r, "id")
|
||||
if raw == "" {
|
||||
return "", apperr.BadRequest("missing profile id", "id", nil)
|
||||
}
|
||||
if _, err := uuid.Parse(raw); err != nil {
|
||||
return "", apperr.BadRequest("profile id must be uuid", "id", err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// parsePagination reads ?limit & ?offset with bounds. Invalid values fall back
|
||||
// to the defaults rather than erroring -- the endpoints are GET, not strict.
|
||||
func parsePagination(r *http.Request, def, max int) (limit, offset int) {
|
||||
limit = def
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= max {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
offset = n
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
47
data-layer/api/internal/handler/render.go
Normal file
47
data-layer/api/internal/handler/render.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Package handler holds HTTP handlers. Handlers parse the request, call into
|
||||
// service, and translate the result (or error) into an HTTP response.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/apperr"
|
||||
)
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Field string `json:"field,omitempty"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, err error) {
|
||||
if ae, ok := apperr.As(err); ok {
|
||||
if ae.RetryAfter > 0 {
|
||||
w.Header().Set("Retry-After", itoa(ae.RetryAfter))
|
||||
}
|
||||
writeJSON(w, ae.Code, errorResponse{Error: ae.Message, Field: ae.Field})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "internal server error"})
|
||||
}
|
||||
|
||||
func itoa(i int) string {
|
||||
const digits = "0123456789"
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
pos := len(buf)
|
||||
for i > 0 {
|
||||
pos--
|
||||
buf[pos] = digits[i%10]
|
||||
i /= 10
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
125
data-layer/api/internal/handler/saved_query_handler.go
Normal file
125
data-layer/api/internal/handler/saved_query_handler.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/apperr"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/middleware"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/model"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/repo"
|
||||
)
|
||||
|
||||
type SavedQueryHandler struct {
|
||||
repo *repo.SavedQueryRepo
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewSavedQueryHandler(r *repo.SavedQueryRepo, log *zap.Logger) *SavedQueryHandler {
|
||||
return &SavedQueryHandler{repo: r, log: log}
|
||||
}
|
||||
|
||||
type createSavedQueryRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=200"`
|
||||
Kind string `json:"kind" validate:"required,oneof=events sql funnel retention session"`
|
||||
Spec map[string]any `json:"spec" validate:"required"`
|
||||
}
|
||||
|
||||
type updateSavedQueryRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=200"`
|
||||
Spec map[string]any `json:"spec" validate:"required"`
|
||||
}
|
||||
|
||||
func (h *SavedQueryHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req createSavedQueryRequest
|
||||
if err := decodeAndValidate(r, &req); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
ws := middleware.WorkspaceFromCtx(r.Context())
|
||||
q, err := h.repo.Create(r.Context(), model.SavedQuery{
|
||||
WorkspaceID: ws,
|
||||
Name: req.Name,
|
||||
Kind: req.Kind,
|
||||
Spec: req.Spec,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, q)
|
||||
}
|
||||
|
||||
func (h *SavedQueryHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
limit, offset := parsePagination(r, 50, 500)
|
||||
ws := middleware.WorkspaceFromCtx(r.Context())
|
||||
qs, err := h.repo.List(r.Context(), ws, limit, offset)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": qs, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
func (h *SavedQueryHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseSavedQueryID(r)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
ws := middleware.WorkspaceFromCtx(r.Context())
|
||||
q, err := h.repo.Get(r.Context(), ws, id)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, q)
|
||||
}
|
||||
|
||||
func (h *SavedQueryHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseSavedQueryID(r)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
var req updateSavedQueryRequest
|
||||
if err := decodeAndValidate(r, &req); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
ws := middleware.WorkspaceFromCtx(r.Context())
|
||||
q, err := h.repo.Update(r.Context(), ws, id, req.Name, req.Spec)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, q)
|
||||
}
|
||||
|
||||
func (h *SavedQueryHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseSavedQueryID(r)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
ws := middleware.WorkspaceFromCtx(r.Context())
|
||||
if err := h.repo.Delete(r.Context(), ws, id); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func parseSavedQueryID(r *http.Request) (string, error) {
|
||||
raw := chi.URLParam(r, "id")
|
||||
if raw == "" {
|
||||
return "", apperr.BadRequest("missing query id", "id", nil)
|
||||
}
|
||||
if _, err := uuid.Parse(raw); err != nil {
|
||||
return "", apperr.BadRequest("query id must be uuid", "id", err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
47
data-layer/api/internal/handler/sql_handler.go
Normal file
47
data-layer/api/internal/handler/sql_handler.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/middleware"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/service"
|
||||
)
|
||||
|
||||
type SQLHandler struct {
|
||||
svc *service.SQLService
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewSQLHandler(svc *service.SQLService, log *zap.Logger) *SQLHandler {
|
||||
return &SQLHandler{svc: svc, log: log}
|
||||
}
|
||||
|
||||
type customSQLRequest struct {
|
||||
SQL string `json:"sql" validate:"required,min=1,max=20000"`
|
||||
}
|
||||
|
||||
// CustomSQL handles POST /query/sql.
|
||||
func (h *SQLHandler) CustomSQL(w http.ResponseWriter, r *http.Request) {
|
||||
var req customSQLRequest
|
||||
if err := decodeAndValidate(r, &req); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
ws := middleware.WorkspaceFromCtx(r.Context())
|
||||
|
||||
res, err := h.svc.Run(r.Context(), req.SQL)
|
||||
if err != nil {
|
||||
h.log.Warn("custom sql rejected",
|
||||
zap.String("workspace_id", ws),
|
||||
zap.Error(err))
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
h.log.Info("custom sql ok",
|
||||
zap.String("workspace_id", ws),
|
||||
zap.Int("rows", res.RowCount),
|
||||
zap.Int64("duration_ms", res.DurationMS))
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
Reference in New Issue
Block a user