Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Add users collection pagination #636

Merged
merged 12 commits into from
Feb 22, 2025
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