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: 2 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DB_DRIVER=sqlite
DB_FILENAME=:memory:
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}
NODE_ENV=test npx tsx --test ${TEST_FILES}

lint:
npx tsc --noemit
Expand Down
8 changes: 7 additions & 1 deletion src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ if (process.env.PUBLIC_URI === undefined) {
// that we may be missing a .env file.
//
// This is the only required environment variable.
dotenv.config({path: dirname(fileURLToPath(import.meta.url)) + '/../.env'});
if(process.env.NODE_ENV === 'test'){
dotenv.config({ path: './.env.test' });
}
else{
dotenv.config({path: dirname(fileURLToPath(import.meta.url)) + '/../.env'});
}

} else {
console.warn('/env.js was loaded twice?');
}
45 changes: 38 additions & 7 deletions 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 @@ -46,26 +47,57 @@ export class PrincipalService {

}

async findAll(type: 'user'): Promise<User[]>;
async findAll(type: 'user', page: number): Promise<PaginatedResult<User>>;
async findAll(type: 'group'): Promise<Group[]>;
async findAll(type: 'app'): Promise<App[]>;
async findAll(): Promise<Principal[]>;
async findAll(type?: PrincipalType): Promise<Principal[]> {
async findAll(type?: PrincipalType, page?: number): Promise<Principal[] | PaginatedResult<Principal>> {

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

const result = await db('principals')
.where(filters);
const pageSize = 100;
const total = (await getPrincipalStats()).user;

let result: PrincipalsRecord[] = [];
let hasNextPage = false;

if(type && page !== undefined){

page = page < 1 ? 1 : page;
const offset = (page - 1) * pageSize;

hasNextPage = (offset + pageSize) < total;

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

} else {
result = await db('principals')
.where(filters);
}

const principals: Principal[] = [];
for (const principal of result) {
principals.push(recordToModel(principal));
}
return principals;

if(type && page !== undefined){
return {
principals,
total,
page,
pageSize,
hasNextPage,
};
} else {
return principals;
}

}

Expand Down Expand Up @@ -403,8 +435,7 @@ export async function getPrincipalStats(): Promise<PrincipalStats> {

}


function recordToModel(user: PrincipalsRecord): Principal {
export function recordToModel(user: PrincipalsRecord): Principal {

return {
id: user.id,
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> = {
principals: T[];
total: number;
page: number;
pageSize: number;
hasNextPage: boolean;
}

/**
* The App Client refers to a single set of credentials for an app.
*
Expand Down
9 changes: 7 additions & 2 deletions src/user/controller/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ 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.findAll('user', page);
const users = paginatedResult.principals;

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

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

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

}

Expand Down
30 changes: 26 additions & 4 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 { Principal, Group, User, PrincipalIdentity, PaginatedResult } from '../../types.ts';
import { 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(embeddedUsers: HalResource[], paginatedResult: PaginatedResult<User>): HalResource {

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

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

const hal: HalResource = {
_links: {
'self': { href: '/user' },
'self': { href: `/user?page=${currentPage}` },
'item': users.map( user => ({
href: user.href,
title: user.nickname,
Expand All @@ -20,9 +24,27 @@ 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'] = { href: `/user?page=${nextPage}` };
}

const hasPrevPage = currentPage > 1;
if(hasPrevPage){
const prevPage = currentPage - 1;
hal._links['previous'] = { href: `/user?page=${prevPage}` };
}

if(hasNextPage || hasPrevPage){
hal._links['first'] = { href: '/user?page=1' };
hal._links['last'] = { href: `/user?page=${totalPages}` };
}

if (embeddedUsers.length) {
hal._embedded = {
item: embeddedUsers
Expand Down
85 changes: 85 additions & 0 deletions test/pagination/services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { strict as assert } from 'node:assert';
import { after, before, describe, it } from 'node:test';

import * as dotenv from 'dotenv';
dotenv.config({ path: './.env.test'});

import { PrincipalService, recordToModel } from '../../src/principal/service.ts';
import db, { insertAndGetId } from '../../src/database.ts';

describe('findAll users pagination', () => {

const userRecordsDb: unknown[] = [];
before(async () => {

await db.schema.createTable('principals', (table) => {
table.increments('id').primary();
table.string('external_id').notNullable();
table.string('nickname').notNullable();
table.integer('type').notNullable();
table.boolean('active').notNullable();
table.timestamp('created_at').defaultTo(db.fn.now());
table.timestamp('modified_at').defaultTo(db.fn.now());
table.boolean('system').notNullable();
});

// 3 pages worth of users
for(let i = 1; i < 251; i++){
const data = {
external_id: i.toString(),
nickname: `User ${i}`,
type: 1,
active: 1,
created_at: Date.now(),
modified_at: Date.now(),
system: 0
};

const id = await insertAndGetId('principals', data);
userRecordsDb.push({...data, id});
}
});

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

it('should display first page', async () => {
const principalService = new PrincipalService('insecure');

const currentPage = 1;
const { principals, pageSize, page, hasNextPage, total } = await principalService.findAll('user', currentPage);
const expectedUsers = userRecordsDb.slice(0, pageSize).map((data) => recordToModel(data));

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

it('should display second page', async () => {
const principalService = new PrincipalService('insecure');

const currentPage = 2;
const { principals, pageSize, page, hasNextPage, total } = await principalService.findAll('user', currentPage);
const expectedUsers = userRecordsDb.slice(pageSize, 200).map((data) => recordToModel(data));

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

it('should display last (third) page', async () => {
const principalService = new PrincipalService('insecure');

const currentPage = 3;
const { principals, page, hasNextPage, total } = await principalService.findAll('user', currentPage);
const expectedUsers = userRecordsDb.slice(200, userRecordsDb.length).map((data) => recordToModel(data));

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