Skip to content

Commit 6a5b77b

Browse files
committed
feat: implement get device list with cursor based pagination
1 parent 549c493 commit 6a5b77b

File tree

8 files changed

+261
-9
lines changed

8 files changed

+261
-9
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package pagination
2+
3+
import (
4+
"encoding/base64"
5+
"fmt"
6+
"strconv"
7+
"strings"
8+
"time"
9+
10+
"github.com/google/uuid"
11+
)
12+
13+
const (
14+
DefaultLimit = 2
15+
MaxLimit = 10
16+
)
17+
18+
type Cursor struct {
19+
ID uuid.UUID
20+
CreatedAt time.Time
21+
}
22+
23+
func Encode(c Cursor) string {
24+
payload := fmt.Sprintf("%d|%s", c.CreatedAt.UTC().UnixNano(), c.ID.String())
25+
return base64.RawURLEncoding.EncodeToString([]byte(payload))
26+
}
27+
28+
func Decode(raw string) (Cursor, error) {
29+
data, err := base64.RawURLEncoding.DecodeString(raw)
30+
if err != nil {
31+
return Cursor{}, fmt.Errorf("invalid cursor encoding")
32+
}
33+
34+
parts := strings.SplitN(string(data), "|", 2)
35+
if len(parts) != 2 {
36+
return Cursor{}, fmt.Errorf("invalid cursor format")
37+
}
38+
39+
id, err := uuid.Parse(parts[1])
40+
if err != nil {
41+
return Cursor{}, fmt.Errorf("invalid cursor uuid")
42+
}
43+
44+
ts, err := strconv.ParseInt(parts[0], 10, 64)
45+
if err != nil {
46+
return Cursor{}, err
47+
}
48+
49+
return Cursor{
50+
CreatedAt: time.Unix(0, ts).UTC(),
51+
ID: id,
52+
}, nil
53+
}
54+
55+
func NewCursor(id uuid.UUID, createdAt time.Time) *Cursor {
56+
return &Cursor{
57+
ID: id,
58+
CreatedAt: createdAt,
59+
}
60+
}
61+
62+
func ClampLimit(n int) int {
63+
if n <= 0 {
64+
return DefaultLimit
65+
}
66+
if n > MaxLimit {
67+
return MaxLimit
68+
}
69+
return n
70+
}

internal/db/device_repository.go

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/jackc/pgx/v5"
1212
"github.com/jackc/pgx/v5/pgconn"
1313
"github.com/jackc/pgx/v5/pgxpool"
14+
"github.com/raphico/go-device-telemetry-api/internal/common/pagination"
1415
"github.com/raphico/go-device-telemetry-api/internal/domain/device"
1516
"github.com/raphico/go-device-telemetry-api/internal/domain/user"
1617
)
@@ -61,7 +62,11 @@ func (r *DeviceRepository) Create(ctx context.Context, device *device.Device) er
6162
return nil
6263
}
6364

64-
func (r *DeviceRepository) FindById(ctx context.Context, id device.DeviceID, userId user.UserID) (*device.Device, error) {
65+
func (r *DeviceRepository) FindById(
66+
ctx context.Context,
67+
id device.DeviceID,
68+
userId user.UserID,
69+
) (*device.Device, error) {
6570
var (
6671
deviceID uuid.UUID
6772
userID uuid.UUID
@@ -110,3 +115,99 @@ func (r *DeviceRepository) FindById(ctx context.Context, id device.DeviceID, use
110115
)
111116

112117
}
118+
119+
func (r *DeviceRepository) FindDevices(
120+
ctx context.Context,
121+
userID user.UserID,
122+
limit int,
123+
cursor *pagination.Cursor,
124+
) ([]*device.Device, *pagination.Cursor, error) {
125+
var (
126+
query string
127+
args []any
128+
)
129+
130+
if cursor == nil {
131+
query = `
132+
SELECT id, user_id, name, device_type, status, metadata, created_at, updated_at
133+
FROM devices
134+
WHERE user_id = $1
135+
ORDER BY created_at ASC, id ASC
136+
LIMIT $2
137+
`
138+
args = []any{userID, limit + 1}
139+
} else {
140+
query = `
141+
SELECT id, user_id, name, device_type, status, metadata, created_at, updated_at
142+
FROM devices
143+
WHERE user_id = $1
144+
AND (created_at, id) > ($2, $3)
145+
ORDER BY created_at ASC, id ASC
146+
LIMIT $4
147+
`
148+
args = []any{userID, cursor.CreatedAt, cursor.ID, limit + 1}
149+
}
150+
151+
rows, err := r.db.Query(ctx, query, args...)
152+
if err != nil {
153+
return nil, nil, fmt.Errorf("query devices: %w", err)
154+
}
155+
defer rows.Close()
156+
157+
var result []*device.Device
158+
159+
for rows.Next() {
160+
var (
161+
deviceID uuid.UUID
162+
uID uuid.UUID
163+
name string
164+
deviceType string
165+
status string
166+
metadata []byte
167+
createdAt time.Time
168+
updatedAt time.Time
169+
)
170+
171+
if err := rows.Scan(
172+
&deviceID,
173+
&uID,
174+
&name,
175+
&deviceType,
176+
&status,
177+
&metadata,
178+
&createdAt,
179+
&updatedAt,
180+
); err != nil {
181+
return nil, nil, fmt.Errorf("scan device: %w", err)
182+
}
183+
184+
dev, err := device.RehydrateDevice(
185+
device.DeviceID(deviceID),
186+
user.UserID(uID),
187+
name,
188+
deviceType,
189+
status,
190+
metadata,
191+
createdAt,
192+
updatedAt,
193+
)
194+
if err != nil {
195+
return nil, nil, fmt.Errorf("rehydrate device: %w", err)
196+
}
197+
198+
result = append(result, dev)
199+
}
200+
201+
if err := rows.Err(); err != nil {
202+
return nil, nil, fmt.Errorf("rows error: %w", err)
203+
}
204+
205+
var nextCur *pagination.Cursor
206+
if len(result) > limit {
207+
lastVisible := result[limit-1]
208+
result = result[:limit]
209+
nextCur = pagination.NewCursor(uuid.UUID(lastVisible.ID), lastVisible.CreatedAt)
210+
}
211+
212+
return result, nextCur, nil
213+
}

internal/domain/device/repository.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package device
33
import (
44
"context"
55

6+
"github.com/raphico/go-device-telemetry-api/internal/common/pagination"
67
"github.com/raphico/go-device-telemetry-api/internal/domain/user"
78
)
89

910
type Repository interface {
1011
Create(ctx context.Context, device *Device) error
1112
FindById(ctx context.Context, id DeviceID, userId user.UserID) (*Device, error)
13+
FindDevices(ctx context.Context, userId user.UserID, limit int, cursor *pagination.Cursor) ([]*Device, *pagination.Cursor, error)
1214
}

internal/domain/device/service.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package device
33
import (
44
"context"
55

6+
"github.com/raphico/go-device-telemetry-api/internal/common/pagination"
67
"github.com/raphico/go-device-telemetry-api/internal/domain/user"
78
)
89

@@ -41,3 +42,12 @@ func (s *Service) GetDevice(ctx context.Context, id DeviceID, userId user.UserID
4142

4243
return device, nil
4344
}
45+
46+
func (s *Service) ListUserDevices(
47+
ctx context.Context,
48+
userID user.UserID,
49+
limit int,
50+
cursor *pagination.Cursor,
51+
) ([]*Device, *pagination.Cursor, error) {
52+
return s.repo.FindDevices(ctx, userID, limit, cursor)
53+
}

internal/domain/device/type.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ const (
66
DeviceTypeTemperatureSensor DeviceType = "temperature_sensor"
77
DeviceTypeHumiditySensor DeviceType = "humidity_sensor"
88
DeviceTypeMotionSensor DeviceType = "motion_sensor"
9+
DeviceTypeSecurityCamera DeviceType = "security_camera"
910
)
1011

1112
var validDeviceTypes = map[DeviceType]struct{}{
1213
DeviceTypeTemperatureSensor: {},
1314
DeviceTypeHumiditySensor: {},
1415
DeviceTypeMotionSensor: {},
16+
DeviceTypeSecurityCamera: {},
1517
}
1618

1719
func NewDeviceType(value string) (DeviceType, error) {

internal/http/auth_handler.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ type registerUserResponse struct {
4444
Email string `json:"email"`
4545
}
4646

47-
func (h *AuthHandler) RegisterUser(w http.ResponseWriter, r *http.Request) {
47+
func (h *AuthHandler) HandleRegisterUser(w http.ResponseWriter, r *http.Request) {
4848
var req registerUserRequest
4949
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
5050
WriteJSONError(w, http.StatusBadRequest, invalidRequest, "Invalid request body")
@@ -100,7 +100,7 @@ type tokenResponse struct {
100100
ExpiresIn int `json:"expires_in"`
101101
}
102102

103-
func (h *AuthHandler) LoginUser(w http.ResponseWriter, r *http.Request) {
103+
func (h *AuthHandler) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
104104
var req loginUserRequest
105105
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
106106
WriteJSONError(w, http.StatusBadRequest, invalidRequest, "Invalid request body")
@@ -142,7 +142,7 @@ func (h *AuthHandler) LoginUser(w http.ResponseWriter, r *http.Request) {
142142
WriteJSON(w, http.StatusOK, resp, nil)
143143
}
144144

145-
func (h *AuthHandler) RefreshAccessToken(w http.ResponseWriter, r *http.Request) {
145+
func (h *AuthHandler) HandleRefreshAccessToken(w http.ResponseWriter, r *http.Request) {
146146
cookie, err := r.Cookie("refresh_token")
147147
if err != nil {
148148
WriteJSONError(w, http.StatusBadRequest, invalidRequest, "Refresh token missing")
@@ -181,7 +181,7 @@ func (h *AuthHandler) RefreshAccessToken(w http.ResponseWriter, r *http.Request)
181181
WriteJSON(w, http.StatusOK, resp, nil)
182182
}
183183

184-
func (h *AuthHandler) LogoutUser(w http.ResponseWriter, r *http.Request) {
184+
func (h *AuthHandler) HandleLogoutUser(w http.ResponseWriter, r *http.Request) {
185185
cookie, err := r.Cookie("refresh_token")
186186
if err != nil {
187187
w.WriteHeader(http.StatusNoContent)

internal/http/device_handler.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import (
55
"errors"
66
"fmt"
77
"net/http"
8+
"strconv"
89

910
"github.com/go-chi/chi/v5"
11+
"github.com/raphico/go-device-telemetry-api/internal/common/pagination"
1012
"github.com/raphico/go-device-telemetry-api/internal/domain/device"
1113
"github.com/raphico/go-device-telemetry-api/internal/domain/user"
1214
"github.com/raphico/go-device-telemetry-api/internal/logger"
@@ -137,3 +139,67 @@ func (h *DeviceHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request)
137139

138140
WriteJSON(w, http.StatusOK, res, nil)
139141
}
142+
143+
type listDevicesMeta struct {
144+
NextCursor string `json:"next_cursor,omitempty"`
145+
Limit int `json:"limit"`
146+
}
147+
148+
func (h *DeviceHandler) HandleListDevices(w http.ResponseWriter, r *http.Request) {
149+
userId, ok := GetUserID(r.Context())
150+
if !ok {
151+
h.log.Debug(fmt.Sprint("missing user id in context", "path", r.URL.Path))
152+
WriteUnauthorizedError(w)
153+
return
154+
}
155+
156+
limit := pagination.DefaultLimit
157+
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
158+
if v, err := strconv.Atoi(limitStr); err != nil {
159+
WriteJSONError(w, http.StatusBadRequest, invalidRequest, "limit must be a positive integer")
160+
return
161+
} else {
162+
limit = pagination.ClampLimit(v)
163+
}
164+
}
165+
166+
var cur *pagination.Cursor
167+
if cstr := r.URL.Query().Get("cursor"); cstr != "" {
168+
if decoded, err := pagination.Decode(cstr); err != nil {
169+
WriteJSONError(w, http.StatusBadRequest, invalidRequest, err.Error())
170+
return
171+
} else {
172+
cur = &decoded
173+
}
174+
}
175+
176+
devs, next, err := h.device.ListUserDevices(r.Context(), userId, limit, cur)
177+
if err != nil {
178+
WriteInternalError(w)
179+
return
180+
}
181+
182+
out := make([]deviceResponse, 0, len(devs))
183+
for _, d := range devs {
184+
out = append(out, deviceResponse{
185+
ID: d.ID.String(),
186+
Name: d.Name.String(),
187+
DeviceType: string(d.DeviceType),
188+
Status: string(d.Status),
189+
Metadata: d.Metadata,
190+
})
191+
}
192+
193+
var nextStr string
194+
if next != nil {
195+
s := pagination.Encode(*next)
196+
nextStr = s
197+
}
198+
199+
meta := listDevicesMeta{
200+
NextCursor: nextStr,
201+
Limit: limit,
202+
}
203+
204+
WriteJSON(w, http.StatusOK, out, meta)
205+
}

internal/http/router.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,19 @@ func NewRouter(
2626

2727
r.Route("/api/v1", func(r chi.Router) {
2828
r.Route("/auth", func(r chi.Router) {
29-
r.Post("/register", authHandler.RegisterUser)
30-
r.Post("/login", authHandler.LoginUser)
31-
r.Post("/refresh", authHandler.RefreshAccessToken)
29+
r.Post("/register", authHandler.HandleRegisterUser)
30+
r.Post("/login", authHandler.HandleLoginUser)
31+
r.Post("/refresh", authHandler.HandleRefreshAccessToken)
3232
})
3333

3434
r.Group(func(r chi.Router) {
3535
r.Use(userMw.RequireAuthMiddleware)
3636

37-
r.Post("/auth/logout", authHandler.LogoutUser)
37+
r.Post("/auth/logout", authHandler.HandleLogoutUser)
3838

3939
r.Route("/devices", func(r chi.Router) {
4040
r.Post("/", deviceHandler.HandleCreateDevice)
41+
r.Get("/", deviceHandler.HandleListDevices)
4142
r.Get("/{id}", deviceHandler.HandleGetDevice)
4243
})
4344
})

0 commit comments

Comments
 (0)