Skip to content

Commit 32fe36e

Browse files
authored
Merge pull request #183 from marcoshuck/feature/list_tasks
List tasks: Cursor-based pagination
2 parents d862d71 + 8a94577 commit 32fe36e

File tree

11 files changed

+255
-112
lines changed

11 files changed

+255
-112
lines changed

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ test-html:
3333
go tool cover -html=coverage.out -o=coverage.html
3434

3535
ci/compose-up:
36-
docker-compose -f ./deployments/local/ci.docker-compose.yml up -d --build
36+
docker compose -f ./deployments/local/ci.docker-compose.yml up -d --build
3737

3838
ci/compose-down:
39-
docker-compose -f ./deployments/local/ci.docker-compose.yml down --rmi all
39+
docker compose -f ./deployments/local/ci.docker-compose.yml down --rmi all
4040

4141
test-e2e:
4242
npx playwright test

api/tasks/v1/tasks.pb.go

Lines changed: 87 additions & 88 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/tasks/v1/tasks.proto

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,6 @@ message ListTasksRequest {
135135
reserved "parent";
136136

137137
int32 page_size = 2 [
138-
(buf.validate.field).int32.gt = 0,
139138
(google.api.field_behavior) = OPTIONAL
140139
];
141140
string page_token = 3 [

deployments/local/ci.docker-compose.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
version: "3.9"
21
services:
32
app:
43
image: "ghcr.io/marcoshuck/todo/app:latest"

deployments/local/docker-compose.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
version: "3.9"
21
services:
32
app:
43
image: "marcoshuck/todo-app"

internal/serializer/page_token.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package serializer
2+
3+
import (
4+
"encoding/base64"
5+
"time"
6+
)
7+
8+
// EncodePageToken converts the given timestamp into a base64-encoded page token.
9+
func EncodePageToken(t time.Time) string {
10+
return base64.URLEncoding.EncodeToString([]byte(t.Format(time.RFC3339)))
11+
}
12+
13+
// DecodePageToken converts the given token into a valid timestamp.
14+
// It returns an error if the token is not a valid representation of a base64-encoded
15+
// timestamp.
16+
func DecodePageToken(token string) (time.Time, error) {
17+
b, err := base64.URLEncoding.DecodeString(token)
18+
if err != nil {
19+
return time.Time{}, err
20+
}
21+
return time.Parse(time.RFC3339, string(b))
22+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package serializer
2+
3+
import (
4+
"github.com/stretchr/testify/assert"
5+
"testing"
6+
"time"
7+
)
8+
9+
func TestEncodePageToken(t *testing.T) {
10+
now := time.Now()
11+
token := EncodePageToken(now)
12+
assert.NotNil(t, token)
13+
var expected string
14+
assert.IsType(t, expected, token)
15+
assert.NotEmpty(t, token)
16+
}
17+
18+
func TestDecodePageToken(t *testing.T) {
19+
now := time.Now()
20+
token := EncodePageToken(now)
21+
22+
out, err := DecodePageToken(token)
23+
assert.NoError(t, err)
24+
assert.Equal(t, now.Year(), out.Year())
25+
assert.Equal(t, now.Month(), out.Month())
26+
assert.Equal(t, now.Day(), out.Day())
27+
assert.Equal(t, now.Hour(), out.Hour())
28+
assert.Equal(t, now.Minute(), out.Minute())
29+
assert.Equal(t, now.Second(), out.Second())
30+
}
31+
32+
func TestDecodePageToken_InvalidToken(t *testing.T) {
33+
out, err := DecodePageToken("1234")
34+
assert.Error(t, err)
35+
assert.Zero(t, out)
36+
}

internal/service/tasks.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import (
55
"errors"
66
tasksv1 "github.com/marcoshuck/todo/api/tasks/v1"
77
"github.com/marcoshuck/todo/internal/domain"
8+
"github.com/marcoshuck/todo/internal/serializer"
89
"go.opentelemetry.io/otel/metric"
910
"go.opentelemetry.io/otel/trace"
1011
"go.uber.org/zap"
1112
"google.golang.org/grpc/codes"
1213
"google.golang.org/grpc/status"
1314
"gorm.io/gorm"
15+
"time"
1416
)
1517

1618
// tasks implements tasksv1.TasksWriterServiceServer.
@@ -48,18 +50,41 @@ func (svc *tasks) ListTasks(ctx context.Context, request *tasksv1.ListTasksReque
4850
svc.logger.Debug("Getting task list", zap.Int32("page_size", request.GetPageSize()), zap.String("page_token", request.GetPageToken()))
4951
span := trace.SpanFromContext(ctx)
5052
defer span.End()
53+
5154
span.AddEvent("Getting tasks from the database")
55+
if request.GetPageSize() == 0 {
56+
request.PageSize = 50
57+
}
58+
59+
query := svc.db.Model(&domain.Task{}).WithContext(ctx)
60+
if len(request.GetPageToken()) > 0 {
61+
updatedAt, err := serializer.DecodePageToken(request.GetPageToken())
62+
if err == nil {
63+
svc.logger.Debug("Getting records older than page token", zap.Time("updated_at", updatedAt))
64+
query = query.Where("updated_at < ?", updatedAt)
65+
}
66+
}
67+
query = query.Limit(int(request.GetPageSize() + 1)).Order("updated_at DESC")
68+
5269
var out []domain.Task
53-
err := svc.db.Model(&domain.Task{}).WithContext(ctx).Find(&out).Error
70+
err := query.Find(&out).Error
5471
if err != nil {
5572
svc.logger.Error("Failed to list tasks", zap.Error(err))
5673
span.RecordError(err)
5774
return nil, status.Errorf(codes.Unavailable, "failed to get task: %v", err)
5875
}
76+
77+
var nextPageToken string
78+
if len(out) > int(request.GetPageSize()) {
79+
nextPageToken = serializer.EncodePageToken(out[request.GetPageSize()-1].UpdatedAt)
80+
svc.logger.Debug("Generating next page token", zap.Int32("page_size", request.GetPageSize()), zap.Int("count", len(out)), zap.String("page_token", nextPageToken), zap.Uint("last_element_id", out[request.GetPageSize()].ID))
81+
out = out[:request.GetPageSize()]
82+
}
83+
5984
svc.logger.Debug("Returning task list", zap.Int32("page_size", request.GetPageSize()), zap.String("page_token", request.GetPageToken()), zap.Int("count", len(out)))
6085
res := tasksv1.ListTasksResponse{
6186
Tasks: make([]*tasksv1.Task, len(out)),
62-
NextPageToken: "",
87+
NextPageToken: nextPageToken,
6388
}
6489
for i, task := range out {
6590
res.Tasks[i] = task.API()
@@ -77,6 +102,9 @@ func (svc *tasks) CreateTask(ctx context.Context, request *tasksv1.CreateTaskReq
77102
svc.logger.Debug("Filling out task information")
78103
span.AddEvent("Parsing task from API request")
79104
task.FromAPI(request.GetTask())
105+
now := time.Now()
106+
task.CreatedAt = now
107+
task.UpdatedAt = now
80108
span.AddEvent("Persisting task in the database")
81109
svc.logger.Debug("Persisting task in the database", zap.String("task.title", request.GetTask().GetTitle()))
82110
err := svc.db.Model(&domain.Task{}).WithContext(ctx).Create(&task).Error

internal/service/tasks_test.go

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package service
22

33
import (
44
"context"
5+
"fmt"
56
tasksv1 "github.com/marcoshuck/todo/api/tasks/v1"
67
"github.com/marcoshuck/todo/internal/domain"
78
"github.com/stretchr/testify/suite"
@@ -13,6 +14,7 @@ import (
1314
"gorm.io/driver/sqlite"
1415
"gorm.io/gorm"
1516
"testing"
17+
"time"
1618
)
1719

1820
func TestTasksServiceSuite(t *testing.T) {
@@ -24,6 +26,7 @@ type TasksServiceTestSuite struct {
2426
db *gorm.DB
2527
writer tasksv1.TasksWriterServiceServer
2628
reader tasksv1.TasksReaderServiceServer
29+
logger *zap.Logger
2730
}
2831

2932
func (suite *TasksServiceTestSuite) SetupSuite() {
@@ -32,12 +35,17 @@ func (suite *TasksServiceTestSuite) SetupSuite() {
3235

3336
func (suite *TasksServiceTestSuite) SetupTest() {
3437
var err error
38+
39+
suite.logger, err = zap.NewDevelopment()
40+
suite.Require().NoError(err)
41+
3542
suite.db, err = gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
3643
suite.Require().NoError(err)
44+
suite.db = suite.db.Debug()
3745
suite.Require().NoError(suite.db.Migrator().AutoMigrate(&domain.Task{}))
3846

39-
suite.writer = NewTasksWriter(suite.db, zap.NewNop(), noop.NewMeterProvider().Meter(""))
40-
suite.reader = NewTasksReader(suite.db, zap.NewNop(), noop.NewMeterProvider().Meter(""))
47+
suite.writer = NewTasksWriter(suite.db, suite.logger, noop.NewMeterProvider().Meter(""))
48+
suite.reader = NewTasksReader(suite.db, suite.logger, noop.NewMeterProvider().Meter(""))
4149
}
4250

4351
func (suite *TasksServiceTestSuite) TearDownTest() {
@@ -63,6 +71,8 @@ func (suite *TasksServiceTestSuite) TestCreate_Success() {
6371
suite.Assert().NoError(err)
6472
suite.Assert().NotNil(res)
6573
suite.Assert().Equal(title, res.GetTitle())
74+
suite.Assert().NotZero(res.GetCreateTime().AsTime())
75+
suite.Assert().NotZero(res.GetUpdateTime().AsTime())
6676

6777
var after int64
6878
suite.Require().NoError(suite.db.Model(&domain.Task{}).Count(&after).Error)
@@ -108,25 +118,38 @@ func (suite *TasksServiceTestSuite) TestList_Empty() {
108118
func (suite *TasksServiceTestSuite) TestList_Success() {
109119
ctx := context.Background()
110120

111-
for i := 0; i < 10; i++ {
112-
_, err := suite.writer.CreateTask(ctx, &tasksv1.CreateTaskRequest{
113-
Task: &tasksv1.Task{
114-
Title: "A test",
121+
list := make([]domain.Task, 0, 10)
122+
for i := 1; i <= 10; i++ {
123+
list = append(list, domain.Task{
124+
Model: gorm.Model{
125+
CreatedAt: time.Now().Add(-time.Duration(i) * time.Hour),
126+
UpdatedAt: time.Now().Add(-time.Duration(i) * time.Hour),
115127
},
128+
Title: fmt.Sprintf("%s %d", suite.T().Name(), i),
116129
})
117-
suite.Require().NoError(err)
118130
}
131+
suite.Require().NoError(suite.db.Create(list).Error)
119132

120133
var expected int64
121134
suite.Require().NoError(suite.db.Model(&domain.Task{}).Count(&expected).Error)
122135

123136
response, err := suite.reader.ListTasks(ctx, &tasksv1.ListTasksRequest{
124-
PageSize: 0,
137+
PageSize: 5,
125138
PageToken: "",
126139
})
127140
suite.Assert().NoError(err)
128141
suite.Assert().NotEmpty(response.GetTasks())
129-
suite.Assert().Len(response.GetTasks(), int(expected))
142+
suite.Assert().Len(response.GetTasks(), 5)
143+
suite.Assert().NotEmpty(response.GetNextPageToken())
144+
145+
response, err = suite.reader.ListTasks(ctx, &tasksv1.ListTasksRequest{
146+
PageSize: 5,
147+
PageToken: response.GetNextPageToken(),
148+
})
149+
suite.Assert().NoError(err)
150+
suite.Assert().NotEmpty(response.GetTasks())
151+
suite.Assert().Len(response.GetTasks(), 5)
152+
suite.Assert().Empty(response.GetNextPageToken())
130153
}
131154

132155
func (suite *TasksServiceTestSuite) TestDelete_NotFound() {

tests/tasks.spec.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {expect, test} from '@playwright/test';
2-
import {createTask, deleteTask, getTask, undeleteTask, updateTask} from "./tasks.utils";
2+
import {createTask, deleteTask, getTask, listTasks, undeleteTask, updateTask} from "./tasks.utils";
3+
import {Task} from "../api/tasks/v1/tasks_pb";
34

45

56
test('POST /v1/tasks', async ({request}) => {
@@ -35,8 +36,30 @@ test('GET /v1/tasks/:id', async ({request}) => {
3536
expect(output.title).toEqual(expected.title);
3637
expect(output.description).toEqual(expected.description);
3738
expect(output.id).toEqual(expected.id);
38-
expect(output.createTime).toEqual(expected.createTime);
39-
expect(output.updateTime).toEqual(expected.updateTime);
39+
})
40+
41+
test('GET /v1/tasks', async ({request}) => {
42+
let input = {
43+
title: 'An awesome task',
44+
description: 'An awesome description for an awesome task',
45+
};
46+
47+
const list: Task[] = [];
48+
49+
for (let i = 0; i < 10; i++) {
50+
let task = await createTask(request, input);
51+
list.push(task);
52+
await new Promise(r => setTimeout(r, 1000));
53+
}
54+
55+
let response = await listTasks(request, 5, undefined);
56+
57+
expect(response.data.nextPageToken).not.toBe('');
58+
59+
60+
for (const task of list) {
61+
await deleteTask(request, task.id);
62+
}
4063
})
4164

4265
test('DELETE /v1/tasks/:id', async ({request}) => {

tests/tasks.utils.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {APIRequestContext, expect} from "@playwright/test";
2-
import {Task} from '../api/tasks/v1/tasks_pb';
2+
import {ListTasksResponse, Task} from '../api/tasks/v1/tasks_pb';
33

44
export async function createTask(request: APIRequestContext, input: any): Promise<Task> {
55
// Send the request and wait for the response.
@@ -16,16 +16,31 @@ export async function createTask(request: APIRequestContext, input: any): Promis
1616
}
1717

1818
export async function getTask(request: APIRequestContext, id: bigint) {
19-
const response = await request.get(`/v1/tasks/${id}`)
19+
const response = await request.get(`/v1/tasks/${id}`);
2020
const body = await response.body();
2121
return {
2222
response: response,
2323
data: Task.fromJsonString(body.toString()),
2424
}
2525
}
2626

27+
export async function listTasks(request: APIRequestContext, size: number, nextPageToken: string | undefined) {
28+
const response = await request.get(`/v1/tasks`, {
29+
params: {
30+
"page_size": size,
31+
"page_token": nextPageToken,
32+
},
33+
});
34+
const body = await response.body();
35+
36+
return {
37+
response: response,
38+
data: ListTasksResponse.fromJsonString(body.toString()),
39+
}
40+
}
41+
2742
export async function deleteTask(request: APIRequestContext, id: bigint): Promise<void> {
28-
const response = await request.delete(`/v1/tasks/${id}`)
43+
const response = await request.delete(`/v1/tasks/${id}`);
2944
expect(response.ok()).toBeTruthy();
3045
}
3146

@@ -34,14 +49,14 @@ export async function undeleteTask(request: APIRequestContext, id: bigint): Prom
3449
data: {
3550
id: Number(id),
3651
}
37-
})
52+
});
3853
expect(response.ok()).toBeTruthy();
3954
}
4055

4156
export async function updateTask(request: APIRequestContext, id: bigint, payload: any): Promise<Task> {
4257
const response = await request.patch(`/v1/tasks/${id}`, {
4358
data: payload,
44-
})
59+
});
4560
// Read the body
4661
const body = await response.body();
4762
return Task.fromJsonString(body.toString());

0 commit comments

Comments
 (0)