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:
parent
bc4d3fed53
commit
c5109ace92
2 changed files with 274 additions and 0 deletions
69
internal/api/event.go
Normal file
69
internal/api/event.go
Normal 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
205
internal/api/event_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue