feat: pagination support

This commit is contained in:
ae 2025-04-09 18:10:33 +03:00
parent 47fa47bdc7
commit ebd85c2e6a
Signed by: ae
GPG Key ID: 995EFD5C1B532B3E
6 changed files with 179 additions and 62 deletions

View File

@ -143,10 +143,16 @@ func (q *Queries) ListAdmins(ctx context.Context) ([]User, error) {
const listUsers = `-- name: ListUsers :many const listUsers = `-- name: ListUsers :many
SELECT id, username, password_hash, is_admin, created_at, updated_at FROM users SELECT id, username, password_hash, is_admin, created_at, updated_at FROM users
LIMIT $1 OFFSET $2
` `
func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { type ListUsersParams struct {
rows, err := q.db.Query(ctx, listUsers) Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]User, error) {
rows, err := q.db.Query(ctx, listUsers, arg.Limit, arg.Offset)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -67,7 +67,7 @@ type TokenStore interface {
// Mockable user related database operations interface. // Mockable user related database operations interface.
type UserStore interface { type UserStore interface {
CreateUser(ctx context.Context, arg data.CreateUserParams) (data.User, error) CreateUser(ctx context.Context, arg data.CreateUserParams) (data.User, error)
ListUsers(ctx context.Context) ([]data.User, error) ListUsers(ctx context.Context, arg data.ListUsersParams) ([]data.User, error)
GetUserByID(ctx context.Context, id uuid.UUID) (data.User, error) GetUserByID(ctx context.Context, id uuid.UUID) (data.User, error)
GetUserByUsername(ctx context.Context, username string) (data.User, error) GetUserByUsername(ctx context.Context, username string) (data.User, error)
UpdatePassword(ctx context.Context, arg data.UpdatePasswordParams) error UpdatePassword(ctx context.Context, arg data.UpdatePasswordParams) error
@ -340,7 +340,11 @@ func (rs authResource) OwnerDelete(w http.ResponseWriter, r *http.Request) {
// Handler for listing all users stored in the database. Should only be allowed to be called by // Handler for listing all users stored in the database. Should only be allowed to be called by
// administrator level users. // administrator level users.
func (rs authResource) List(w http.ResponseWriter, r *http.Request) { func (rs authResource) List(w http.ResponseWriter, r *http.Request) {
users, err := rs.Users.ListUsers(r.Context()) limit, offset := getPaginationParams(r)
users, err := rs.Users.ListUsers(r.Context(), data.ListUsersParams{
Limit: limit,
Offset: offset,
})
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to retrieve users") respondError(w, http.StatusInternalServerError, "Failed to retrieve users")
return return

View File

@ -2,13 +2,9 @@ package service
import ( import (
"context" "context"
"crypto/sha1"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings"
"git.umbrella.haus/ae/notatest/internal/data" "git.umbrella.haus/ae/notatest/internal/data"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -292,36 +288,3 @@ func (rs *notesResource) GetFullVersion(w http.ResponseWriter, r *http.Request)
respondJSON(w, http.StatusOK, fullVersion) respondJSON(w, http.StatusOK, fullVersion)
} }
// Parse `limit` and `offset` 32-bit integer URL parameters from the given request. Defaults to
// limit of 50 and offset 0 if parameters are missing/invalid.
func getPaginationParams(r *http.Request) (limit int32, offset int32) {
defaultLimit := 50
defaultOffset := 0
limitStr := r.URL.Query().Get("limit")
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
defaultLimit = l
}
}
offsetStr := r.URL.Query().Get("offset")
if offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
defaultOffset = o
}
}
return int32(defaultLimit), int32(defaultOffset)
}
// Concatenate the title and content strings, calculate a SHA-1 hash of the resulting string, and
// return the resulting hash as a string.
func sha1ContentHash(title, content string) string {
hashContent := title + content
hash := sha1.Sum([]byte(hashContent))
hashStr := strings.ToUpper(hex.EncodeToString(hash[:]))
return hashStr
}

View File

@ -9,6 +9,7 @@ import (
"io" "io"
"net/http" "net/http"
"regexp" "regexp"
"strconv"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@ -131,3 +132,36 @@ func validateUsername(username string) error {
return nil return nil
} }
// Parse `limit` and `offset` 32-bit integer URL parameters from the given request. Defaults to
// limit of 50 and offset 0 if parameters are missing/invalid.
func getPaginationParams(r *http.Request) (limit int32, offset int32) {
defaultLimit := 50
defaultOffset := 0
limitStr := r.URL.Query().Get("limit")
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
defaultLimit = l
}
}
offsetStr := r.URL.Query().Get("offset")
if offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
defaultOffset = o
}
}
return int32(defaultLimit), int32(defaultOffset)
}
// Concatenate the title and content strings, calculate a SHA-1 hash of the resulting string, and
// return the resulting hash as a string.
func sha1ContentHash(title, content string) string {
hashContent := title + content
hash := sha1.Sum([]byte(hashContent))
hashStr := strings.ToUpper(hex.EncodeToString(hash[:]))
return hashStr
}

View File

@ -51,19 +51,19 @@ func TestValidatePassword(t *testing.T) {
}, },
} }
for _, tt := range tests { for _, tc := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Mock HTTP client if needed // Mock HTTP client if needed
if tt.mockHTTP != nil { if tc.mockHTTP != nil {
mockClient := new(MockHTTPClient) mockClient := new(MockHTTPClient)
tt.mockHTTP(mockClient) tc.mockHTTP(mockClient)
} }
err := validatePassword(tt.password) err := validatePassword(tc.password)
if tt.wantErr == "" { if tc.wantErr == "" {
assert.NoError(t, err) assert.NoError(t, err)
} else { } else {
assert.ErrorContains(t, err, tt.wantErr) assert.ErrorContains(t, err, tc.wantErr)
} }
}) })
} }
@ -103,10 +103,10 @@ func TestPasswordEntropyCalculation(t *testing.T) {
}, },
} }
for _, tt := range tests { for _, tc := range tests {
t.Run(tt.password, func(t *testing.T) { t.Run(tc.password, func(t *testing.T) {
entropy := passwordvalidator.GetEntropy(tt.password) entropy := passwordvalidator.GetEntropy(tc.password)
assert.InDelta(t, tt.entropy, entropy, 1.0) assert.InDelta(t, tc.entropy, entropy, 1.0)
}) })
} }
} }
@ -139,13 +139,13 @@ func TestValidateUsername(t *testing.T) {
}, },
} }
for _, tt := range tests { for _, tc := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
err := validateUsername(tt.input) err := validateUsername(tc.input)
if tt.wantErr == "" { if tc.wantErr == "" {
assert.NoError(t, err) assert.NoError(t, err)
} else { } else {
assert.ErrorContains(t, err, tt.wantErr) assert.ErrorContains(t, err, tc.wantErr)
} }
}) })
} }
@ -174,10 +174,119 @@ func TestNormalizeUsername(t *testing.T) {
}, },
} }
for _, tt := range tests { for _, tc := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
got := normalizeUsername(tt.input) got := normalizeUsername(tc.input)
assert.Equal(t, tt.want, got) assert.Equal(t, tc.want, got)
})
}
}
func TestGetPaginationParams(t *testing.T) {
tests := []struct {
name string
query string
expectedLimit int32
expectedOffset int32
}{
{
name: "defaults",
query: "",
expectedLimit: 50,
expectedOffset: 0,
},
{
name: "valid limit and offset",
query: "limit=25&offset=30",
expectedLimit: 25,
expectedOffset: 30,
},
{
name: "invalid limit",
query: "limit=abc&offset=5",
expectedLimit: 50,
expectedOffset: 5,
},
{
name: "limit zero",
query: "limit=0",
expectedLimit: 50,
expectedOffset: 0,
},
{
name: "limit negative",
query: "limit=-5",
expectedLimit: 50,
expectedOffset: 0,
},
{
name: "offset negative",
query: "offset=-10",
expectedLimit: 50,
expectedOffset: 0,
},
{
name: "invalid offset",
query: "offset=xyz",
expectedLimit: 50,
expectedOffset: 0,
},
{
name: "valid offset zero",
query: "offset=0",
expectedLimit: 50,
expectedOffset: 0,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest("GET", "/test?"+tc.query, nil)
assert.Nil(t, err)
limit, offset := getPaginationParams(req)
assert.Equal(t, tc.expectedLimit, limit)
assert.Equal(t, tc.expectedOffset, offset)
})
}
}
func TestSHA1ContentHash(t *testing.T) {
tests := []struct {
name string
title string
content string
expected string
}{
{
name: "empty strings",
title: "",
content: "",
expected: "DA39A3EE5E6B4B0D3255BFEF95601890AFD80709",
},
{
name: "title only",
title: "hello",
content: "",
expected: "AAF4C61DDCC5E8A2DABEDE0F3B482CD9AEA9434D",
},
{
name: "content only",
title: "",
content: "world",
expected: "7C211433F02071597741E6FF5A8EA34789ABBF43",
},
{
name: "both title and content",
title: "hello",
content: "world",
expected: "6ADFB183A4A2C94A2F92DAB5ADE762A47889A5A1",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := sha1ContentHash(tc.title, tc.content)
assert.Equal(t, tc.expected, result)
}) })
} }
} }

View File

@ -9,7 +9,8 @@ VALUES ($1, $2, true)
RETURNING *; RETURNING *;
-- name: ListUsers :many -- name: ListUsers :many
SELECT * FROM users; SELECT * FROM users
LIMIT $1 OFFSET $2;
-- name: ListAdmins :many -- name: ListAdmins :many
SELECT * FROM users SELECT * FROM users