feat: pagination support
This commit is contained in:
parent
47fa47bdc7
commit
ebd85c2e6a
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user