feat: expose authenticated growth report API
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: ZarinPal Merchant Analytics API
|
||||
version: 1.0.0
|
||||
summary: Contract-first API for authenticated merchant analytics.
|
||||
version: 1.1.0
|
||||
summary: Contract-first API for authenticated merchant analytics and growth reports.
|
||||
description: |
|
||||
Approved MVP interface. This document is the API source of truth; generated
|
||||
Approved analytics and AI growth-report interface. This document is the API source of truth; generated
|
||||
frontend types must come from this contract.
|
||||
|
||||
Merchant scope is always derived from the authenticated server-side session.
|
||||
|
||||
204
internal/api/growth.go
Normal file
204
internal/api/growth.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growthreport"
|
||||
)
|
||||
|
||||
var growthReportIDPattern = regexp.MustCompile(`^growth_[a-f0-9]{24}$`)
|
||||
|
||||
type growthReportService interface {
|
||||
Create(context.Context, int64, int64, growthreport.CreateRequest, string) (growthreport.Record, bool, error)
|
||||
Get(context.Context, int64, string) (growthreport.Record, error)
|
||||
Latest(context.Context, int64, string) (growthreport.Record, error)
|
||||
}
|
||||
|
||||
func (s *Server) createGrowthReport(w http.ResponseWriter, r *http.Request) {
|
||||
if s.growth == nil {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
key := r.Header.Get("Idempotency-Key")
|
||||
if !validIdempotencyKey(key) {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "A valid Idempotency-Key is required")
|
||||
return
|
||||
}
|
||||
var input growthreport.CreateRequest
|
||||
if err := decodeStrictJSON(w, r, &input); err != nil || len(input.TerminalID) > 128 {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid growth report request")
|
||||
return
|
||||
}
|
||||
session := sessionFrom(r.Context())
|
||||
record, cached, err := s.growth.Create(r.Context(), session.UserID, session.MerchantID, input, key)
|
||||
if err != nil {
|
||||
s.handleGrowthCreateError(w, r, err)
|
||||
return
|
||||
}
|
||||
status := http.StatusAccepted
|
||||
if cached {
|
||||
status = http.StatusOK
|
||||
}
|
||||
w.Header().Set("Location", "/api/v1/analytics/growth-reports/"+record.ReportID)
|
||||
s.writeJSON(w, status, record.Report)
|
||||
}
|
||||
|
||||
func (s *Server) handleGrowthCreateError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
var limit *growthreport.RefreshLimitError
|
||||
switch {
|
||||
case errors.Is(err, growthreport.ErrDisabled):
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
case errors.Is(err, growthreport.ErrUnknownTerminal):
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown terminal")
|
||||
case errors.Is(err, growthreport.ErrIdempotencyConflict):
|
||||
s.problem(w, r, 409, "IDEMPOTENCY_CONFLICT", "Idempotency key was already used for another request")
|
||||
case errors.As(err, &limit):
|
||||
seconds := max(int64(1), int64(time.Until(limit.NextRefreshAt).Seconds()))
|
||||
w.Header().Set("Retry-After", strconv.FormatInt(seconds, 10))
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(429)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"type": "https://zarinpal.amiirkhl.ir/docs/errors/refresh-limited", "title": "Too Many Requests", "status": 429, "code": "REFRESH_LIMITED", "detail": "Daily refresh is not available yet", "instance": r.URL.Path, "requestId": w.Header().Get("X-Request-ID"), "nextRefreshAt": limit.NextRefreshAt})
|
||||
case errors.Is(err, growthreport.ErrRateLimited):
|
||||
s.problem(w, r, 429, "RATE_LIMITED", "Too many growth report requests")
|
||||
default:
|
||||
s.logger.Error("growth report creation failed", "error", err)
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Growth analytics unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) latestGrowthReport(w http.ResponseWriter, r *http.Request) {
|
||||
if s.growth == nil {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
for key := range r.URL.Query() {
|
||||
if key != "terminalId" {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown query parameter")
|
||||
return
|
||||
}
|
||||
}
|
||||
terminalID := r.URL.Query().Get("terminalId")
|
||||
if len(terminalID) > 128 {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid terminal")
|
||||
return
|
||||
}
|
||||
record, err := s.growth.Latest(r.Context(), sessionFrom(r.Context()).MerchantID, terminalID)
|
||||
s.writeGrowthResult(w, r, record, err)
|
||||
}
|
||||
|
||||
func (s *Server) getGrowthReport(w http.ResponseWriter, r *http.Request) {
|
||||
if s.growth == nil {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("reportId")
|
||||
if !growthReportIDPattern.MatchString(id) {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
record, err := s.growth.Get(r.Context(), sessionFrom(r.Context()).MerchantID, id)
|
||||
s.writeGrowthResult(w, r, record, err)
|
||||
}
|
||||
|
||||
func (s *Server) writeGrowthResult(w http.ResponseWriter, r *http.Request, record growthreport.Record, err error) {
|
||||
if errors.Is(err, growthreport.ErrNotFound) {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Growth analytics unavailable")
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, 200, record.Report)
|
||||
}
|
||||
|
||||
func (s *Server) growthReportEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if s.growth == nil {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("reportId")
|
||||
if !growthReportIDPattern.MatchString(id) {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
merchantID := sessionFrom(r.Context()).MerchantID
|
||||
record, err := s.growth.Get(r.Context(), merchantID, id)
|
||||
if errors.Is(err, growthreport.ErrNotFound) {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Growth analytics unavailable")
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
s.problem(w, r, 500, "INTERNAL_ERROR", "Streaming unavailable")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
heartbeat := time.NewTicker(15 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
last := ""
|
||||
for {
|
||||
eventID := record.ReportID + ":" + strconv.FormatInt(record.UpdatedAt.UnixNano(), 10)
|
||||
if eventID != last {
|
||||
payload, _ := json.Marshal(map[string]any{"eventId": eventID, "report": record.Report})
|
||||
_, _ = fmt.Fprintf(w, "id: %s\nevent: report.snapshot\ndata: %s\n\n", eventID, payload)
|
||||
flusher.Flush()
|
||||
last = eventID
|
||||
}
|
||||
if record.Status.Terminal() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
record, err = s.growth.Get(r.Context(), merchantID, id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
case <-heartbeat.C:
|
||||
_, _ = fmt.Fprint(w, ": heartbeat\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validIdempotencyKey(key string) bool {
|
||||
if len(key) < 8 || len(key) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, value := range []byte(key) {
|
||||
if value < 0x21 || value > 0x7e {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func decodeStrictJSON(w http.ResponseWriter, r *http.Request, value any) error {
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(value); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
return errors.New("trailing JSON")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
129
internal/api/growth_test.go
Normal file
129
internal/api/growth_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growthreport"
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/identity"
|
||||
)
|
||||
|
||||
func TestValidIdempotencyKeyUsesPrintableAllowlist(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name, key string
|
||||
want bool
|
||||
}{{"valid", "growth-123456", true}, {"short", "short", false}, {"space", "growth key", false}, {"unicode", "رشد-گزارش", false}}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := validIdempotencyKey(tt.key); got != tt.want {
|
||||
t.Fatalf("validIdempotencyKey(%q)=%v", tt.key, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestDecodeStrictJSONRejectsTrailingAndUnknownFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, body := range []string{`{"refresh":true}{}`, `{"unexpected":true}`} {
|
||||
request := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
||||
recorder := httptest.NewRecorder()
|
||||
var value struct {
|
||||
Refresh bool `json:"refresh"`
|
||||
}
|
||||
if err := decodeStrictJSON(recorder, request, &value); err == nil {
|
||||
t.Fatalf("decodeStrictJSON(%q) error=nil", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestGrowthReportIDPatternRejectsTenantEnumerationInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !growthReportIDPattern.MatchString("growth_0123456789abcdef01234567") {
|
||||
t.Fatal("valid report id rejected")
|
||||
}
|
||||
for _, id := range []string{"growth_1", "../growth_0123456789abcdef01234567", "run_0123456789abcdef01234567"} {
|
||||
if growthReportIDPattern.MatchString(id) {
|
||||
t.Fatalf("unsafe id accepted: %q", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeGrowthService struct {
|
||||
get func(int64, string) (growthreport.Record, error)
|
||||
create func() (growthreport.Record, bool, error)
|
||||
}
|
||||
|
||||
func (f fakeGrowthService) Create(_ context.Context, _, _ int64, _ growthreport.CreateRequest, _ string) (growthreport.Record, bool, error) {
|
||||
return f.create()
|
||||
}
|
||||
func (f fakeGrowthService) Get(_ context.Context, merchantID int64, id string) (growthreport.Record, error) {
|
||||
return f.get(merchantID, id)
|
||||
}
|
||||
func (f fakeGrowthService) Latest(_ context.Context, merchantID int64, terminal string) (growthreport.Record, error) {
|
||||
return f.get(merchantID, terminal)
|
||||
}
|
||||
|
||||
func TestGrowthEndpointsRequireAuthentication(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: fakeGrowthService{}}
|
||||
request := httptest.NewRequest("GET", "/api/v1/analytics/growth-reports/latest", nil)
|
||||
response := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
func TestGetGrowthReportHidesOtherMerchantResource(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := fakeGrowthService{get: func(merchantID int64, _ string) (growthreport.Record, error) {
|
||||
if merchantID != 7 {
|
||||
t.Fatalf("merchant=%d", merchantID)
|
||||
}
|
||||
return growthreport.Record{}, growthreport.ErrNotFound
|
||||
}}
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service}
|
||||
request := httptest.NewRequest("GET", "/", nil)
|
||||
request.SetPathValue("reportId", "growth_0123456789abcdef01234567")
|
||||
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7}))
|
||||
response := httptest.NewRecorder()
|
||||
server.getGrowthReport(response, request)
|
||||
if response.Code != 404 {
|
||||
t.Fatalf("status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
func TestRefreshLimitProblemIncludesNextRefreshAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
next := time.Now().UTC().Add(time.Hour)
|
||||
service := fakeGrowthService{create: func() (growthreport.Record, bool, error) {
|
||||
return growthreport.Record{}, false, &growthreport.RefreshLimitError{NextRefreshAt: next}
|
||||
}}
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service}
|
||||
request := httptest.NewRequest("POST", "/api/v1/analytics/growth-reports", strings.NewReader(`{"refresh":true}`))
|
||||
request.Header.Set("Idempotency-Key", "growth-refresh-1")
|
||||
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7, UserID: 3}))
|
||||
response := httptest.NewRecorder()
|
||||
server.createGrowthReport(response, request)
|
||||
if response.Code != 429 || !strings.Contains(response.Body.String(), "nextRefreshAt") {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
func TestGrowthSSEAuthorizesBeforeSettingStreamHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := fakeGrowthService{get: func(int64, string) (growthreport.Record, error) {
|
||||
return growthreport.Record{}, growthreport.ErrNotFound
|
||||
}}
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service}
|
||||
request := httptest.NewRequest("GET", "/", nil)
|
||||
request.SetPathValue("reportId", "growth_0123456789abcdef01234567")
|
||||
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7}))
|
||||
response := httptest.NewRecorder()
|
||||
server.growthReportEvents(response, request)
|
||||
if response.Code != 404 || !strings.HasPrefix(response.Header().Get("Content-Type"), "application/problem+json") {
|
||||
t.Fatalf("status=%d content-type=%q", response.Code, response.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ var errExportBytesExceeded = errors.New("export exceeds byte limit")
|
||||
type Server struct {
|
||||
identity *identity.Service
|
||||
analytics *analytics.Service
|
||||
growth growthReportService
|
||||
logger *slog.Logger
|
||||
frontendOrigin string
|
||||
cookieSecure bool
|
||||
@@ -36,8 +37,12 @@ type Server struct {
|
||||
type sessionKey struct{}
|
||||
type tokenKey struct{}
|
||||
|
||||
func NewServer(identityService *identity.Service, analyticsService *analytics.Service, logger *slog.Logger, frontendOrigin string, cookieSecure bool) *Server {
|
||||
return &Server{identity: identityService, analytics: analyticsService, logger: logger, frontendOrigin: frontendOrigin, cookieSecure: cookieSecure}
|
||||
func NewServer(identityService *identity.Service, analyticsService *analytics.Service, logger *slog.Logger, frontendOrigin string, cookieSecure bool, growthService ...growthReportService) *Server {
|
||||
server := &Server{identity: identityService, analytics: analyticsService, logger: logger, frontendOrigin: frontendOrigin, cookieSecure: cookieSecure}
|
||||
if len(growthService) > 0 {
|
||||
server.growth = growthService[0]
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
@@ -54,6 +59,10 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("GET /api/v1/analytics/session-details", s.authenticate(http.HandlerFunc(s.details)))
|
||||
mux.Handle("GET /api/v1/analytics/session-details.csv", s.authenticate(http.HandlerFunc(s.exportCSV)))
|
||||
mux.Handle("GET /api/v1/analytics/sessions/{sessionId}", s.authenticate(http.HandlerFunc(s.sessionDetail)))
|
||||
mux.Handle("POST /api/v1/analytics/growth-reports", s.authenticate(s.csrf(http.HandlerFunc(s.createGrowthReport))))
|
||||
mux.Handle("GET /api/v1/analytics/growth-reports/latest", s.authenticate(http.HandlerFunc(s.latestGrowthReport)))
|
||||
mux.Handle("GET /api/v1/analytics/growth-reports/{reportId}", s.authenticate(http.HandlerFunc(s.getGrowthReport)))
|
||||
mux.Handle("GET /api/v1/analytics/growth-reports/{reportId}/events", s.authenticate(http.HandlerFunc(s.growthReportEvents)))
|
||||
return s.boundary(mux)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user