From c5109ace92d7b73da40ff6971e5b58070e674884 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 1 Mar 2026 05:05:10 +0300 Subject: [PATCH] internal/api: add event model with validation Supports both pageview and custom event types Signed-off-by: NotAShelf Change-Id: Iaf48291cd952865ea9ec21361ae33c746a6a6964 --- internal/api/event.go | 69 +++++++++++++ internal/api/event_test.go | 205 +++++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 internal/api/event.go create mode 100644 internal/api/event_test.go diff --git a/internal/api/event.go b/internal/api/event.go new file mode 100644 index 0000000..324670a --- /dev/null +++ b/internal/api/event.go @@ -0,0 +1,69 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" +) + +const ( + maxEventSize = 4 * 1024 // 4KB + maxPathLen = 2048 + maxRefLen = 2048 +) + +// Represents an incoming analytics event +type Event struct { + Domain string `json:"d"` // domain + Path string `json:"p"` // path + Referrer string `json:"r"` // referrer URL + Event string `json:"e"` // custom event name (empty for pageviews) + Width int `json:"w"` // screen width for device classification +} + +// Parses an event from the request body with size limits +func ParseEvent(body io.Reader) (*Event, error) { + // Limit read size to prevent memory exhaustion + limited := io.LimitReader(body, maxEventSize+1) + + data, err := io.ReadAll(limited) + if err != nil { + return nil, fmt.Errorf("failed to read body: %w", err) + } + + if len(data) > maxEventSize { + return nil, fmt.Errorf("event payload too large: %d bytes (max %d)", len(data), maxEventSize) + } + + var event Event + if err := json.Unmarshal(data, &event); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + + return &event, nil +} + +// Validate checks if the event is valid for the given domain +func (e *Event) Validate(expectedDomain string) error { + if e.Domain == "" { + return fmt.Errorf("domain is required") + } + + if e.Domain != expectedDomain { + return fmt.Errorf("domain mismatch: got %q, expected %q", e.Domain, expectedDomain) + } + + if e.Path == "" { + return fmt.Errorf("path is required") + } + + if len(e.Path) > maxPathLen { + return fmt.Errorf("path too long: %d bytes (max %d)", len(e.Path), maxPathLen) + } + + if len(e.Referrer) > maxRefLen { + return fmt.Errorf("referrer too long: %d bytes (max %d)", len(e.Referrer), maxRefLen) + } + + return nil +} diff --git a/internal/api/event_test.go b/internal/api/event_test.go new file mode 100644 index 0000000..7929422 --- /dev/null +++ b/internal/api/event_test.go @@ -0,0 +1,205 @@ +package api + +import ( + "strings" + "testing" +) + +func TestParseEvent_Valid(t *testing.T) { + tests := []struct { + name string + body string + want Event + }{ + { + name: "pageview with all fields", + body: `{"d":"example.com","p":"/home","r":"https://google.com","e":"","w":1024}`, + want: Event{ + Domain: "example.com", + Path: "/home", + Referrer: "https://google.com", + Event: "", + Width: 1024, + }, + }, + { + name: "custom event", + body: `{"d":"example.com","p":"/signup","r":"","e":"signup","w":0}`, + want: Event{ + Domain: "example.com", + Path: "/signup", + Referrer: "", + Event: "signup", + Width: 0, + }, + }, + { + name: "minimal fields", + body: `{"d":"example.com","p":"/"}`, + want: Event{ + Domain: "example.com", + Path: "/", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseEvent(strings.NewReader(tt.body)) + if err != nil { + t.Fatalf("ParseEvent() error = %v", err) + } + + if got.Domain != tt.want.Domain { + t.Errorf("Domain = %v, want %v", got.Domain, tt.want.Domain) + } + if got.Path != tt.want.Path { + t.Errorf("Path = %v, want %v", got.Path, tt.want.Path) + } + if got.Referrer != tt.want.Referrer { + t.Errorf("Referrer = %v, want %v", got.Referrer, tt.want.Referrer) + } + if got.Event != tt.want.Event { + t.Errorf("Event = %v, want %v", got.Event, tt.want.Event) + } + if got.Width != tt.want.Width { + t.Errorf("Width = %v, want %v", got.Width, tt.want.Width) + } + }) + } +} + +func TestParseEvent_InvalidJSON(t *testing.T) { + tests := []struct { + name string + body string + }{ + { + name: "invalid json", + body: `{invalid json}`, + }, + { + name: "empty body", + body: ``, + }, + { + name: "truncated json", + body: `{"d":"example.com"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseEvent(strings.NewReader(tt.body)) + if err == nil { + t.Error("expected error for invalid JSON, got nil") + } + }) + } +} + +func TestParseEvent_TooLarge(t *testing.T) { + // Create a payload larger than maxEventSize (4KB) + largeBody := `{"d":"example.com","p":"` + strings.Repeat("a", 5000) + `"}` + + _, err := ParseEvent(strings.NewReader(largeBody)) + if err == nil { + t.Error("expected error for too large payload, got nil") + } + if !strings.Contains(err.Error(), "too large") { + t.Errorf("expected 'too large' error, got: %v", err) + } +} + +func TestValidateEvent(t *testing.T) { + tests := []struct { + name string + event Event + domain string + wantErr bool + }{ + { + name: "valid pageview", + event: Event{ + Domain: "example.com", + Path: "/home", + }, + domain: "example.com", + wantErr: false, + }, + { + name: "valid custom event", + event: Event{ + Domain: "example.com", + Path: "/signup", + Event: "signup", + }, + domain: "example.com", + wantErr: false, + }, + { + name: "wrong domain", + event: Event{ + Domain: "wrong.com", + Path: "/home", + }, + domain: "example.com", + wantErr: true, + }, + { + name: "empty domain", + event: Event{ + Domain: "", + Path: "/home", + }, + domain: "example.com", + wantErr: true, + }, + { + name: "empty path", + event: Event{ + Domain: "example.com", + Path: "", + }, + domain: "example.com", + wantErr: true, + }, + { + name: "path too long", + event: Event{ + Domain: "example.com", + Path: "/" + strings.Repeat("a", 3000), + }, + domain: "example.com", + wantErr: true, + }, + { + name: "referrer too long", + event: Event{ + Domain: "example.com", + Path: "/home", + Referrer: strings.Repeat("a", 3000), + }, + domain: "example.com", + wantErr: true, + }, + { + name: "valid long path (under limit)", + event: Event{ + Domain: "example.com", + Path: "/" + strings.Repeat("a", 2000), + }, + domain: "example.com", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.event.Validate(tt.domain) + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}