226 lines
7.2 KiB
Go
226 lines
7.2 KiB
Go
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
|
|
slots := s.growthSSESlots
|
|
if slots == nil {
|
|
slots = defaultGrowthSSESlots
|
|
}
|
|
select {
|
|
case slots <- struct{}{}:
|
|
defer func() { <-slots }()
|
|
default:
|
|
s.problem(w, r, http.StatusTooManyRequests, "RATE_LIMITED", "Too many growth report streams")
|
|
return
|
|
}
|
|
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")
|
|
maxDuration := s.growthSSEMaxDuration
|
|
if maxDuration <= 0 {
|
|
maxDuration = defaultGrowthSSEMaxDuration
|
|
}
|
|
pollInterval := s.growthSSEPollInterval
|
|
if pollInterval <= 0 {
|
|
pollInterval = defaultGrowthSSEPollInterval
|
|
}
|
|
streamContext, cancel := context.WithTimeout(r.Context(), maxDuration)
|
|
defer cancel()
|
|
ticker := time.NewTicker(pollInterval)
|
|
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 <-streamContext.Done():
|
|
return
|
|
case <-ticker.C:
|
|
record, err = s.growth.Get(streamContext, 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
|
|
}
|