internal/api: add event model with validation

Supports both pageview and custom event types

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Iaf48291cd952865ea9ec21361ae33c746a6a6964
This commit is contained in:
raf 2026-03-01 05:05:10 +03:00
commit c5109ace92
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
2 changed files with 274 additions and 0 deletions

69
internal/api/event.go Normal file
View file

@ -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
}

205
internal/api/event_test.go Normal file
View file

@ -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)
}
})
}
}