Skip to content

Commit

Permalink
Feat: Add users collection pagination (#636)
Browse files Browse the repository at this point in the history
* Feat: Add users collection pagination

Signed-off-by: Kaung Zin Hein <[email protected]>

* Update: Paginate using the db query

Signed-off-by: Kaung Zin Hein <[email protected]>

* Refactor: cast page to Number

Signed-off-by: Kaung Zin Hein <[email protected]>

* Test: findAll query

Signed-off-by: Kaung Zin Hein <[email protected]>

* Fix: Add conditional use of `.env.test` file

Signed-off-by: Kaung Zin Hein <[email protected]>

* Delete: hal.collection test

Signed-off-by: Kaung Zin Hein <[email protected]>

* Feat: first and last page links

Signed-off-by: Kaung Zin Hein <[email protected]>

* Refactor: Pass db related env var to `make test` target

Signed-off-by: Kaung Zin Hein <[email protected]>

* Feat: Add generic search function

Signed-off-by: Kaung Zin Hein <[email protected]>

* Refactor: Use `principalService.save()` for integration test

Signed-off-by: Kaung Zin Hein <[email protected]>

* Test: Run migrations on test database

Signed-off-by: Kaung Zin Hein <[email protected]>

* Feat: Add `getUserPageHref` util and tests

Signed-off-by: Kaung Zin Hein <[email protected]>

---------

Signed-off-by: Kaung Zin Hein <[email protected]>
  • Loading branch information
Zen-cronic authored Feb 22, 2025
1 parent 1c330bc commit 737be45
Show file tree
Hide file tree
Showing 7 changed files with 242 additions and 10 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ docker-run:
docker run -it --rm --name $(DOCKER_IMAGE_NAME)-01 $(DOCKER_IMAGE_NAME)

test:
npx tsx --test ${TEST_FILES}
DB_DRIVER=sqlite DB_FILENAME=":memory:" npx tsx --test ${TEST_FILES}

lint:
npx tsc --noemit
Expand Down
33 changes: 32 additions & 1 deletion src/principal/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
BasePrincipal,
Group,
NewPrincipal,
PaginatedResult,
Principal,
PrincipalIdentity,
PrincipalStats,
Expand Down Expand Up @@ -69,6 +70,37 @@ export class PrincipalService {

}

async search<T extends Principal>(type: PrincipalType, page: number = 1): Promise<PaginatedResult<T>> {

this.privileges.require('a12n:principals:list');
const filters: Record<string, any> = {};
filters.type = userTypeToInt(type);

const pageSize = 100;
const offset = (page - 1) * pageSize;

const total = (await getPrincipalStats()).user;
const hasNextPage = (offset + pageSize) < total;

const result = await db('principals')
.where(filters)
.limit(pageSize)
.offset(offset);

const items: T[] = [];
for (const principal of result) {
items.push(recordToModel(principal) as T);
}

return {
items,
total,
page,
pageSize,
hasNextPage,
};
}

async findByIdentity(identity: PrincipalIdentity|string): Promise<Principal> {

this.privileges.require('a12n:principals:list');
Expand Down Expand Up @@ -403,7 +435,6 @@ export async function getPrincipalStats(): Promise<PrincipalStats> {

}


function recordToModel(user: PrincipalsRecord): Principal {

return {
Expand Down
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,17 @@ export type PrincipalStats = {
group: number;
};

/**
* Paginated Result
*/
export type PaginatedResult<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
hasNextPage: boolean;
}

/**
* The App Client refers to a single set of credentials for an app.
*
Expand Down
10 changes: 8 additions & 2 deletions src/user/controller/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ import * as hal from '../formats/hal.ts';
import * as services from '../../services.ts';
import { PrincipalNew } from '../../api-types.ts';
import { HalResource } from 'hal-types';
import { User } from '../../../src/types.ts';

class UserCollectionController extends Controller {

async get(ctx: Context) {

const principalService = new services.principal.PrincipalService(ctx.privileges);
const users = await principalService.findAll('user');

const page = +ctx.request.query.page || 1;

const paginatedResult = await principalService.search<User>('user', page);
const users = paginatedResult.items;

const embed = ctx.request.prefer('transclude').toString().includes('item') || ctx.query.embed?.includes('item');

const embeddedUsers: HalResource[] = [];
Expand All @@ -36,7 +42,7 @@ class UserCollectionController extends Controller {
}
}

ctx.response.body = hal.collection(users, embeddedUsers);
ctx.response.body = hal.collection(paginatedResult, embeddedUsers);

}

Expand Down
39 changes: 34 additions & 5 deletions src/user/formats/hal.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { PrivilegeMap } from '../../privilege/types.ts';
import { Principal, Group, User, PrincipalIdentity } from '../../types.ts';
import { HalResource } from 'hal-types';
import { Principal, Group, User, PrincipalIdentity, PaginatedResult } from '../../types.ts';
import { HalLink, HalResource } from 'hal-types';
import { LazyPrivilegeBox } from '../../privilege/service.ts';
import { UserNewResult } from '../../api-types.ts';

export function collection(users: User[], embeddedUsers: HalResource[]): HalResource {
export function collection(paginatedResult: PaginatedResult<User>, embeddedUsers: HalResource[]): HalResource {

const { items: users, page: currentPage, total, pageSize, hasNextPage } = paginatedResult;

const totalPages = Math.ceil(total / pageSize);

const hal: HalResource = {
_links: {
'self': { href: '/user' },
'self': getUserPageHref(currentPage),
'item': users.map( user => ({
href: user.href,
title: user.nickname,
Expand All @@ -20,9 +24,26 @@ export function collection(users: User[], embeddedUsers: HalResource[]): HalReso
templated: true,
},
},
total: users.length,
total,
currentPage,
totalPages,
};

if(hasNextPage){
const nextPage = currentPage + 1;
hal._links['next'] = getUserPageHref(nextPage);
}

const hasPrevPage = currentPage > 1;
if(hasPrevPage){
const prevPage = currentPage - 1;
hal._links['previous'] = getUserPageHref(prevPage);
}
if(hasNextPage || hasPrevPage){
hal._links['first'] = getUserPageHref(1);
hal._links['last'] = getUserPageHref(totalPages);
}

if (embeddedUsers.length) {
hal._embedded = {
item: embeddedUsers
Expand All @@ -33,6 +54,14 @@ export function collection(users: User[], embeddedUsers: HalResource[]): HalReso

}

function getUserPageHref(page: number): HalLink {
if(page === 1){
return { href: '/user' };
}

return { href: `/user?page=${page}` };
}

/**
* Generate a HAL response for a specific user
*
Expand Down
155 changes: 155 additions & 0 deletions test/pagination/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { strict as assert } from 'node:assert';
import { after, before, describe, it } from 'node:test';

import { PrincipalService } from '../../src/principal/service.ts';
import { PrincipalNew } from '../../src/api-types.ts';
import * as hal from '../../src/user/formats/hal.ts';
import db, { init } from '../../src/database.ts';
import { User } from '../../src/types.ts';
import { HalResource } from 'hal-types';

describe('users pagination', () => {
const principalService = new PrincipalService('insecure');
let users: User[] = [];

before(async () => {
await init();

const hasTable = await db.schema.hasTable('principals');
if (!hasTable) {
await db.schema.createTable('principals', (table) => {
table.increments('id').primary();
table.string('identity').nullable();
table.string('external_id').notNullable();
table.string('nickname').notNullable();
table.integer('type').notNullable();
table.bigInteger('created_at').defaultTo(db.fn.now());
table.bigInteger('modified_at').defaultTo(db.fn.now());
table.boolean('active').notNullable().defaultTo(false);
table.tinyint('system').notNullable().defaultTo(0);
});
}

// 3 pages worth of users
for(let i = 1; i < 251; i++){
const data = {
type: 'user' as PrincipalNew['type'],
nickname: `User ${i}`,
createdAt: new Date(Date.now()),
modifiedAt: new Date(Date.now()),
active: true,
};
await principalService.save(data);
}

users = await principalService.findAll('user');
});

after(async () => {
await db.destroy();
});

describe('search service', () => {
it('should display first page', async () => {
const currentPage = 1;
const { items, pageSize, page, hasNextPage, total } = await principalService.search('user', currentPage);
const expectedUsers = users.slice(0, pageSize);

assert.equal(page, currentPage);
assert.equal(hasNextPage, true);
assert.equal(total, users.length);
assert.deepEqual(items, expectedUsers);
});

it('should display second page', async () => {
const currentPage = 2;
const { items, pageSize, page, hasNextPage, total } = await principalService.search('user', currentPage);
const expectedUsers = users.slice(pageSize, 200);

assert.equal(page, currentPage);
assert.equal(hasNextPage, true);
assert.equal(total, users.length);
assert.deepEqual(items, expectedUsers);
});

it('should display last (third) page', async () => {
const currentPage = 3;
const { items, page, hasNextPage, total } = await principalService.search('user', currentPage);
const expectedUsers = users.slice(200, users.length);

assert.equal(page, currentPage);
assert.equal(hasNextPage, false);
assert.equal(total, users.length);
assert.deepEqual(items, expectedUsers);
});
});

describe('hal.collection links', () => {
const embeddedUsers: HalResource[] = [];

it('should not display `previous` link on first page', async () => {
const currentPage = 1;

const paginatedResult = await principalService.search<User>('user', currentPage);
const halRes = hal.collection(paginatedResult, embeddedUsers);

assert.equal(halRes._links.previous, undefined);
assert.deepEqual(halRes._links.self, {
href: '/user',
});
assert.deepEqual(halRes._links.first, {
href: '/user',
});
assert.deepEqual(halRes._links.last, {
href: '/user?page=3',
});
assert.deepEqual(halRes._links.next, {
href: '/user?page=2',
});
});

it('should not display `next` link on last page', async () => {
const currentPage = 3;

const paginatedResult = await principalService.search<User>('user', currentPage);
const halRes = hal.collection(paginatedResult, embeddedUsers);

assert.equal(halRes._links.next, undefined);
assert.deepEqual(halRes._links.self, {
href: '/user?page=3',
});
assert.deepEqual(halRes._links.first, {
href: '/user',
});
assert.deepEqual(halRes._links.last, {
href: '/user?page=3',
});
assert.deepEqual(halRes._links.previous, {
href: '/user?page=2',
});
});

it('should display both `previous` & `next` links on middle page', async () => {
const currentPage = 2;

const paginatedResult = await principalService.search<User>('user', currentPage);
const halRes = hal.collection(paginatedResult, embeddedUsers);

assert.deepEqual(halRes._links.self, {
href: '/user?page=2',
});
assert.deepEqual(halRes._links.first, {
href: '/user',
});
assert.deepEqual(halRes._links.last, {
href: '/user?page=3',
});
assert.deepEqual(halRes._links.previous, {
href: '/user',
});
assert.deepEqual(halRes._links.next, {
href: '/user?page=3',
});
});
});
});
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"DOM",
"ES2022"
],
"declaration": true
"declaration": true,
},
"include": [
"src/**/*"
Expand Down

0 comments on commit 737be45

Please sign in to comment.