Skip to content

Commit 82c0436

Browse files
authored
feat: add crud and history manager #14
- Added generic CRUD foundation to avoid code duplication across managers - Implemented History Manager with pagination to track HTTP request executions for debugging
2 parents 62bef8a + 4b71de8 commit 82c0436

File tree

11 files changed

+1188
-0
lines changed

11 files changed

+1188
-0
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-- +goose Up
2+
CREATE TABLE history (
3+
id INTEGER PRIMARY KEY AUTOINCREMENT,
4+
collection_id INTEGER,
5+
collection_name TEXT,
6+
endpoint_name TEXT,
7+
method TEXT NOT NULL,
8+
url TEXT NOT NULL,
9+
status_code INTEGER NOT NULL,
10+
duration INTEGER NOT NULL, -- duration in milliseconds
11+
response_size INTEGER DEFAULT 0,
12+
request_headers TEXT DEFAULT '{}',
13+
query_params TEXT DEFAULT '{}',
14+
request_body TEXT DEFAULT '',
15+
response_body TEXT DEFAULT '',
16+
response_headers TEXT DEFAULT '{}',
17+
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
18+
);
19+
20+
CREATE INDEX idx_history_collection_id ON history(collection_id);
21+
CREATE INDEX idx_history_executed_at ON history(executed_at);
22+
CREATE INDEX idx_history_status_code ON history(status_code);
23+
24+
-- +goose Down
25+
DROP INDEX IF EXISTS idx_history_status_code;
26+
DROP INDEX IF EXISTS idx_history_executed_at;
27+
DROP INDEX IF EXISTS idx_history_collection_id;
28+
DROP TABLE IF EXISTS history;

db/queries/history.sql

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
-- name: CreateHistoryEntry :one
2+
INSERT INTO history (
3+
collection_id, collection_name, endpoint_name,
4+
method, url, status_code, duration, response_size,
5+
request_headers, query_params, request_body,
6+
response_body, response_headers, executed_at
7+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
8+
RETURNING *;
9+
10+
-- name: GetHistoryById :one
11+
SELECT * FROM history
12+
WHERE id = ?;
13+
14+
-- name: GetHistoryByCollection :many
15+
SELECT id, endpoint_name, status_code, executed_at, url, method FROM history
16+
WHERE collection_id = ?
17+
ORDER BY executed_at DESC
18+
LIMIT ? OFFSET ?;
19+
20+
-- name: CountHistoryByCollection :one
21+
SELECT COUNT(*) FROM history
22+
WHERE collection_id = ?;
23+
24+
-- name: DeleteHistoryEntry :exec
25+
DELETE FROM history
26+
WHERE id = ?;
27+
28+
-- name: DeleteOldHistory :exec
29+
DELETE FROM history
30+
WHERE executed_at < datetime('now', '-30 days');

internal/crud/interfaces.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Package crud provides generic CRUD operations for entities.
2+
package crud
3+
4+
import (
5+
"context"
6+
"errors"
7+
"time"
8+
)
9+
10+
var (
11+
ErrNotFound = errors.New("entity not found")
12+
ErrInvalidInput = errors.New("invalid input")
13+
)
14+
15+
type Entity interface {
16+
GetID() int64
17+
GetName() string
18+
GetCreatedAt() time.Time
19+
GetUpdatedAt() time.Time
20+
}
21+
22+
type Manager[T Entity] interface {
23+
Create(ctx context.Context, name string) (T, error)
24+
Read(ctx context.Context, id int64) (T, error)
25+
Update(ctx context.Context, id int64, name string) (T, error)
26+
Delete(ctx context.Context, id int64) error
27+
List(ctx context.Context) ([]T, error)
28+
}

internal/crud/interfaces_test.go

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
package crud
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
)
8+
9+
type TestEntity struct {
10+
ID int64 `json:"id"`
11+
Name string `json:"name"`
12+
CreatedAt time.Time `json:"created_at"`
13+
UpdatedAt time.Time `json:"updated_at"`
14+
}
15+
16+
func (e TestEntity) GetID() int64 {
17+
return e.ID
18+
}
19+
20+
func (e TestEntity) GetName() string {
21+
return e.Name
22+
}
23+
24+
func (e TestEntity) GetCreatedAt() time.Time {
25+
return e.CreatedAt
26+
}
27+
28+
func (e TestEntity) GetUpdatedAt() time.Time {
29+
return e.UpdatedAt
30+
}
31+
32+
func TestCRUDInterface(t *testing.T) {
33+
ctx := context.Background()
34+
35+
t.Run("Create", func(t *testing.T) {
36+
manager := &testCRUDManager{}
37+
38+
entity, err := manager.Create(ctx, "test-entity")
39+
if err != nil {
40+
t.Fatalf("Create failed: %v", err)
41+
}
42+
43+
if entity.GetName() != "test-entity" {
44+
t.Errorf("expected name 'test-entity', got %s", entity.GetName())
45+
}
46+
47+
if entity.GetID() == 0 {
48+
t.Error("expected ID to be set")
49+
}
50+
51+
if entity.GetCreatedAt().IsZero() {
52+
t.Error("expected CreatedAt to be set")
53+
}
54+
})
55+
56+
t.Run("Read", func(t *testing.T) {
57+
manager := &testCRUDManager{}
58+
59+
// Create entity first
60+
created, err := manager.Create(ctx, "test-read")
61+
if err != nil {
62+
t.Fatalf("Create failed: %v", err)
63+
}
64+
65+
// Read it back
66+
entity, err := manager.Read(ctx, created.GetID())
67+
if err != nil {
68+
t.Fatalf("Read failed: %v", err)
69+
}
70+
71+
if entity.GetID() != created.GetID() {
72+
t.Errorf("expected ID %d, got %d", created.GetID(), entity.GetID())
73+
}
74+
75+
if entity.GetName() != "test-read" {
76+
t.Errorf("expected name 'test-read', got %s", entity.GetName())
77+
}
78+
})
79+
80+
t.Run("Update", func(t *testing.T) {
81+
manager := &testCRUDManager{}
82+
83+
// Create entity first
84+
created, err := manager.Create(ctx, "original-name")
85+
if err != nil {
86+
t.Fatalf("Create failed: %v", err)
87+
}
88+
89+
// Update it
90+
updated, err := manager.Update(ctx, created.GetID(), "updated-name")
91+
if err != nil {
92+
t.Fatalf("Update failed: %v", err)
93+
}
94+
95+
if updated.GetName() != "updated-name" {
96+
t.Errorf("expected name 'updated-name', got %s", updated.GetName())
97+
}
98+
99+
if updated.GetUpdatedAt().Before(created.GetUpdatedAt()) {
100+
t.Error("expected UpdatedAt to be updated")
101+
}
102+
})
103+
104+
t.Run("Delete", func(t *testing.T) {
105+
manager := &testCRUDManager{}
106+
107+
// Create entity first
108+
created, err := manager.Create(ctx, "to-delete")
109+
if err != nil {
110+
t.Fatalf("Create failed: %v", err)
111+
}
112+
113+
// Delete it
114+
err = manager.Delete(ctx, created.GetID())
115+
if err != nil {
116+
t.Fatalf("Delete failed: %v", err)
117+
}
118+
119+
// Verify it's gone
120+
_, err = manager.Read(ctx, created.GetID())
121+
if err == nil {
122+
t.Error("expected Read to fail after Delete")
123+
}
124+
})
125+
126+
t.Run("List", func(t *testing.T) {
127+
manager := &testCRUDManager{}
128+
129+
// Create some entities
130+
names := []string{"entity1", "entity2", "entity3"}
131+
for _, name := range names {
132+
_, err := manager.Create(ctx, name)
133+
if err != nil {
134+
t.Fatalf("Create failed: %v", err)
135+
}
136+
}
137+
138+
// List them
139+
entities, err := manager.List(ctx)
140+
if err != nil {
141+
t.Fatalf("List failed: %v", err)
142+
}
143+
144+
if len(entities) < len(names) {
145+
t.Errorf("expected at least %d entities, got %d", len(names), len(entities))
146+
}
147+
})
148+
}
149+
150+
func TestCRUDValidation(t *testing.T) {
151+
ctx := context.Background()
152+
manager := &testCRUDManager{}
153+
154+
t.Run("Create with empty name", func(t *testing.T) {
155+
_, err := manager.Create(ctx, "")
156+
if err == nil {
157+
t.Error("expected Create with empty name to fail")
158+
}
159+
})
160+
161+
t.Run("Read non-existent entity", func(t *testing.T) {
162+
_, err := manager.Read(ctx, 99999)
163+
if err == nil {
164+
t.Error("expected Read of non-existent entity to fail")
165+
}
166+
})
167+
168+
t.Run("Update non-existent entity", func(t *testing.T) {
169+
_, err := manager.Update(ctx, 99999, "new-name")
170+
if err == nil {
171+
t.Error("expected Update of non-existent entity to fail")
172+
}
173+
})
174+
175+
t.Run("Delete non-existent entity", func(t *testing.T) {
176+
err := manager.Delete(ctx, 99999)
177+
if err == nil {
178+
t.Error("expected Delete of non-existent entity to fail")
179+
}
180+
})
181+
}
182+
183+
type testCRUDManager struct {
184+
entities map[int64]TestEntity
185+
nextID int64
186+
}
187+
188+
func (m *testCRUDManager) Create(ctx context.Context, name string) (TestEntity, error) {
189+
if m.entities == nil {
190+
m.entities = make(map[int64]TestEntity)
191+
m.nextID = 1
192+
}
193+
194+
if name == "" {
195+
return TestEntity{}, ErrInvalidInput
196+
}
197+
198+
now := time.Now()
199+
entity := TestEntity{
200+
ID: m.nextID,
201+
Name: name,
202+
CreatedAt: now,
203+
UpdatedAt: now,
204+
}
205+
206+
m.entities[m.nextID] = entity
207+
m.nextID++
208+
209+
return entity, nil
210+
}
211+
212+
func (m *testCRUDManager) Read(ctx context.Context, id int64) (TestEntity, error) {
213+
if m.entities == nil {
214+
return TestEntity{}, ErrNotFound
215+
}
216+
217+
entity, exists := m.entities[id]
218+
if !exists {
219+
return TestEntity{}, ErrNotFound
220+
}
221+
222+
return entity, nil
223+
}
224+
225+
func (m *testCRUDManager) Update(ctx context.Context, id int64, name string) (TestEntity, error) {
226+
if m.entities == nil {
227+
return TestEntity{}, ErrNotFound
228+
}
229+
230+
entity, exists := m.entities[id]
231+
if !exists {
232+
return TestEntity{}, ErrNotFound
233+
}
234+
235+
if name == "" {
236+
return TestEntity{}, ErrInvalidInput
237+
}
238+
239+
entity.Name = name
240+
entity.UpdatedAt = time.Now()
241+
m.entities[id] = entity
242+
243+
return entity, nil
244+
}
245+
246+
func (m *testCRUDManager) Delete(ctx context.Context, id int64) error {
247+
if m.entities == nil {
248+
return ErrNotFound
249+
}
250+
251+
_, exists := m.entities[id]
252+
if !exists {
253+
return ErrNotFound
254+
}
255+
256+
delete(m.entities, id)
257+
return nil
258+
}
259+
260+
func (m *testCRUDManager) List(ctx context.Context) ([]TestEntity, error) {
261+
if m.entities == nil {
262+
return []TestEntity{}, nil
263+
}
264+
265+
entities := make([]TestEntity, 0, len(m.entities))
266+
for _, entity := range m.entities {
267+
entities = append(entities, entity)
268+
}
269+
270+
return entities, nil
271+
}

internal/crud/utils.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package crud
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/maniac-en/req/internal/log"
8+
)
9+
10+
func ValidateName(name string) error {
11+
name = strings.TrimSpace(name)
12+
if name == "" {
13+
log.Debug("validation failed: empty name")
14+
return fmt.Errorf("name cannot be empty")
15+
}
16+
if len(name) > 100 {
17+
log.Debug("validation failed: name too long", "length", len(name))
18+
return fmt.Errorf("name cannot exceed 100 characters")
19+
}
20+
return nil
21+
}
22+
23+
func ValidateID(id int64) error {
24+
if id <= 0 {
25+
log.Debug("validation failed: invalid ID", "id", id)
26+
return fmt.Errorf("ID must be positive")
27+
}
28+
return nil
29+
}

0 commit comments

Comments
 (0)