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